Listener lifetimes
FRP graphs are held together by references, and getting those references wrong is the most common way to break a SodaFlow program. Listeners that die early stop firing, silently; listeners that never die leak, also silently. Neither throws.
Strong and weak
ListenStrong returns an IStrongListener. It keeps the graph it observes alive, and it implements
IDisposable. Hold it for as long as you need to be able to end the subscription, then
Unlisten() or Dispose() it — they do the same thing. Dropping the handle does not end the
subscription; see below.
Listen returns an IWeakListener. It does not keep the graph alive. Use it when
something else already owns the lifetime and you do not want the subscription extending it.
The short name is the weak one.
Listendoes not root what it observes;ListenStrongdoes. If you are used to an API where the unqualified name is the strong one, this is the opposite way round, and nothing in the name will remind you.Upgrading: these two methods were previously named
Listen(strong) andListenWeak(weak). The names swapped, the behavior did not. Existing code that callsListenand assigns the result to anIStrongListenerstops compiling and is easy to find. Code that discards the result, or holds it in avaror anIListener, keeps compiling and silently becomes a weak subscription — which fails only later, as listeners that quietly stop firing. Rename every pre-existingListencall toListenStrongfirst, then choose which of them genuinely want to be weak. In F# the same applies toStream.listen/Cell.listenand theirlistenS/listenCaliases.
ListenOnce and ListenOnceStrong unsubscribe themselves after the first firing, and divide
the same way: ListenOnce returns an IWeakListener and roots nothing, ListenOnceStrong
returns an IStrongListener that roots the stream until that first firing arrives. Hold the
handle ListenOnce gives you until the firing you are waiting for; see the mistake that does
fail silently, which applies to it exactly as it does to
Listen.
ListenOnceAsync gives you the same thing as a Task<T>, with an optional
CancellationToken. It is strong, necessarily — you get a task rather than a listener, so
there is no handle you could hold.
Upgrading:
ListenOnceused to be strong, andListenOnceStrongis new. This is the same swap the two general listen methods went through, with the same failure mode, and it is sharper here because a one-shot subscription is the kind you are most tempted to fire and forget.s.ListenOnce(handler);as a bare statement compiles before and after; it delivered before and now delivers only if a collection does not happen first. Rename every pre-existingListenOncecall toListenOnceStrong, then choose which of them genuinely want to be weak. In F# the same applies toStream.listenOnceand itslistenOnceSalias, whose strong forms areStream.listenOnceStrongandlistenOnceStrongS.
Dropping the listener ListenStrong returns
// Keeps firing — but nothing can stop it any more.
s.Map(x => x * 2).ListenStrong(Console.WriteLine);
ListenStrong roots the listener in the stream's keep-alive set, so discarding the return value
does not stop delivery. The handler keeps running until the stream itself is disposed or
collected. What you have lost is the handle: there is no longer any way to Unlisten(), so
the subscription overstays rather than dying quietly.
That is deliberate, and pinned by ListenerIsKeptAliveWhileStillListening in the memory
tests — a listener that silently stopped firing would be the worse failure of the two.
Keep the listener whenever you need to be able to stop it:
this.listener = s.Map(x => x * 2).ListenStrong(Console.WriteLine);
In a scoped context, using is the clearer form and is what the test suite uses throughout:
using (result.ListenStrong(@out.Add))
{
s.Send(1);
s.Send(2);
}
The mistake that does fail silently
Listen is the one where dropping the reference stops delivery:
// Wrong: nothing holds the weak listener, so it can be collected and stop firing.
s.Map(x => x * 2).Listen(Console.WriteLine);
// Wrong for the same reason, and easier to write by accident.
s.Map(x => x * 2).ListenOnce(Console.WriteLine);
This compiles, runs, and works — until a garbage collection happens, after which it silently
stops. The node holds your handler through a WeakReference, and the returned
IWeakListener is the only thing keeping it alive — Listen deliberately does not root
that listener anywhere. Drop it and the handler becomes collectable.
Use Listen only when something else owns the lifetime, and hold that reference for
exactly as long as you want the subscription to live. ListenOnce is the same method with a
shorter lifetime: hold its listener until the firing arrives, or call ListenOnceStrong.
When the framework gives you no teardown hook
Some UI frameworks discard a view without ever calling anything you can hook. A WPF
UserControl is the usual case: it goes out of use and is simply dropped, with no Dispose,
and Unloaded is unreliable enough that you cannot hang teardown off it.
Here ListenStrong is not merely awkward, it is the bug. It roots the listener in the source
stream's keep-alive set, so a view model that outlives the control ends up holding it:
ViewModel → stream → keep-alive set → listener → handler → captured `this` → UserControl
Nothing ever breaks that chain, and the control leaks for the lifetime of the view model.
Listen, with the listener held in a field, inverts it:
public partial class RateView : UserControl
{
// Held so the subscription lives exactly as long as this control does.
private readonly IWeakListener listener;
public RateView(RateViewModel vm)
{
this.InitializeComponent();
this.listener = vm.Rate.Listen(r => this.RateText.Text = r.ToString());
}
}
The stream side reaches the handler only through a WeakReference, so the view model does not
root the control. The field keeps delivery running for as long as the control is alive, and the
control becoming unreachable is what unsubscribes it — lifetime coupling without a teardown
hook. The control and its listener form a reference cycle between themselves, which the
collector reclaims as a unit.
Two things to be clear-eyed about. Teardown is not deterministic: between the control falling out of use and the collection that reclaims it, the handler still fires. That is fine when it only updates controls nobody is looking at, and not fine when the handler has effects beyond the control — writing to a store, sending a command — which will keep happening for an unbounded interval. And the pattern is only as good as the control's collectability: a CLR event subscription to a long-lived object, a static, or a binding whose source outlives the control will root the control and the listener with it, leaving a leak that now looks like it handles lifetime correctly.
Do not Send from inside a handler
The XML documentation on both listen methods is explicit: neither StreamSinkExtensionMethods.Send
nor CellSinkExtensionMethods.Send may be called from a handler, and doing so throws — the
reason given is that they are not meant to be used to build new primitives.
When you need a handler to feed another sink, defer it past the transaction boundary:
IListener l = s.Listen(v => Transaction.Post(() => other.Send(v)));
Handlers also carry no thread guarantee — the docs say to make no assumptions about which thread they run on, and not to block.
Machinery you probably do not need
MutableListener and Cleanup are public, but they exist mainly so the library can build its
own primitives. Operational, LoopedStream, TimerSystem and the Switch implementations
all use them; nothing in the test suite uses them the way an application would.
For ordinary subscriptions you do not need either. Both listen methods already tie the
listener's lifetime to the stream's, from opposite ends. Listen gives you a handle that holds
the stream, so the subscription lives exactly as long as you keep the handle. ListenStrong
additionally roots the listener in the stream's keep-alive set, and the listener holds the stream
in turn, so the two form a cycle that the collector reclaims together once you drop your handle.
Neither permanently roots a stream by itself.
The third piece of that machinery, AttachListener, used to be public too and is not any more.
It bound some other listener to a chosen stream's lifetime, and had it actively unlistened —
disconnecting its node from upstream — when that stream was collected, rather than merely
becoming unreachable. That is how a combinator keeps its own wiring alive. Switch
subscribes to the outer cell to learn when the branch changes, and to whichever inner stream is
currently selected; the caller holds neither, only the stream Switch returns. Both
subscriptions are therefore attached to that returned stream, so they live exactly as long as
the result does. Operational, LoopedStream and TimerSystem do the same thing.
Application code could never get into that situation, which is why the method is gone rather
than merely discouraged: building a primitive this way needs the weak Listen overload taking a
node, which is internal, and a handler that sends, which throws. Switch has already done it
for you.
MutableListener is an IListener whose target can be swapped (SetListener, ClearListener,
Unlisten) while the handle stays stable. Switch needs exactly that: the inner subscription is
replaced on every branch change, but attaching a fresh listener each time would grow the stream's
attached-listener list without bound, so one stable handle is attached once and re-pointed.
Cleanup runs an action when it is collected, or immediately via CleanupNow() — finalization
rather than deterministic disposal, so it runs eventually.
Verifying it
These invariants are enforced by SodaFlow.Tests.Memory, which asserts them using weak
references, the node's listener set, and the manager's registry count — no profiler required
for most of them. If you change cleanup behavior, those tests are the specification.
A separate set in the same project counts live objects with dotMemory. Those are tagged
[Ignore("Requires dotMemory.")] so they skip on CI and run locally when you have it
installed.