JavaRush /Courses /Kotlin SELF /Reliable event dispatch: snapshot, try/catch, reentrancy

Reliable event dispatch: snapshot, try/catch, reentrancy

Kotlin SELF
Level 57, Lesson 3
Available

1. Why naive dispatch breaks in a real program

When you write emit, it’s very easy to fall into the trap of “it’s just a list, I’ll just iterate it”. In the first exercises, that’s even true. But as soon as your code becomes even a little bit alive, handlers start behaving like real people in a queue: someone leaves, someone suddenly brings a friend, and someone faints (an exception) — and the queue still has to keep moving.

Let’s imagine a naive implementation: it already supports subscribe and unsubscribe, but dispatch is still “fragile”.

class Event<T> {
    private val listeners = mutableListOf<(T) -> Unit>()

    fun subscribe(listener: (T) -> Unit): () -> Unit {
        listeners += listener
        return { listeners.remove(listener) }
    }

    fun emit(value: T) {
        for (l in listeners) l(value)
    }
}

On the surface, everything looks nice. But now let’s add two “ordinary” real-life scenarios.

Scenario: unsubscribe inside a handler

Let a handler be “one-shot”: after the first event, it unsubscribes.

fun main() {
    val e = Event<Int>()

    var unsubscribe: (() -> Unit)? = null
    unsubscribe = e.subscribe { x ->
        println("one-time got $x")   // one-time got 1
        unsubscribe?.invoke()
    }

    e.emit(1)
    e.emit(2)
}

Even without digging in, you can see it: during emit the listener list changes. On the JVM, things like this often end unpleasantly (like ConcurrentModificationException) or as “silent” logic bugs: someone gets skipped, someone gets called an extra time.

Scenario: one handler crashes, the others must survive

Suppose you have an audit log and notifications. Notifications crash (for example, there’s a formatting error inside), but the audit must always be recorded.

If emit is not protected, the exception breaks the loop and the remaining handlers won’t be called. And that’s no longer “oops, unpleasant”, it’s “why don’t our reports match?”.

2. Listener snapshot: copy the list before dispatch

If you’ve ever tried to photograph a cat running around the room, you already understand the snapshot idea: you take a picture of “this is how it was at that moment”, and after that the cat can run however it wants — the photo won’t change. For event dispatch, a snapshot is needed for exactly this: we freeze which set of listeners receives the current dispatch, while subscription changes during the dispatch affect only future emit calls.

The key Kotlin tool here is toList(). It creates a list copy of the elements at a specific point in time; further changes to the source MutableList do not affect the copy.

Bad iteration: the “live” list

fun emitUnsafe(value: T) {
    for (l in listeners) l(value)
}

The problem isn’t that the code is “bad” stylistically. The problem is that it can’t withstand reality, where a handler may change the list.

Good iteration: snapshot as the first step

fun emit(value: T) {
    val snapshot = listeners.toList()
    for (l in snapshot) l(value)
}

And here is a very important rule (almost like “put your own oxygen mask on first”): the snapshot must be the first action in emit. If you start dispatching and then suddenly decide to take a snapshot “halfway through”, you’ll get behavior that’s impossible to explain to a person without resorting to shamanic tambourines.

The snapshot approach contract

With snapshot dispatch, we sort of sign an “agreement” with the users of our Event<T>.

  • If a handler subscribes during emit, it will start receiving events only from the next emit.
  • If a handler unsubscribes during emit, it still may receive the current event (because it’s already in the snapshot), but it won’t receive the next ones.

This contract is predictable, simple, and can be explained in a single paragraph. In educational projects, it’s almost always the best choice.

3. try/catch around each handler

With exceptions in handlers there’s a subtle point: we want dispatch to be “resilient”, but we don’t want to turn errors into a “black hole” where nothing is visible and nobody is responsible. So the correct strategy is usually: catch the exception for each listener separately, record the fact of the error (at least println), and continue dispatching.

Why exactly this way? Because try/catch around the whole dispatch will catch the error, but it will stop the loop. That is, one listener breaks all the others. But if we wrap each one, the error is isolated.

Incorrect protection: one try/catch for everything

fun emit(value: T) {
    try {
        val snapshot = listeners.toList()
        for (l in snapshot) l(value)
    } catch (e: Exception) {
        println("Emit failed: ${e.message}")
    }
}

It looks “protected”, but in practice: if the first handler crashes, the second and third won’t be called.

Correct protection: try/catch per handler

fun emit(value: T) {
    val snapshot = listeners.toList()
    for (l in snapshot) {
        try {
            l(value)
        } catch (e: Exception) {
            println("Listener failed: ${e.message}")
        }
    }
}

Yes, it’s a few more lines. But you’re buying predictability and system survivability with those lines.

4. Reentrancy: a handler can call emit again

The word “reentrancy” sounds like we’re about to build a spaceship. In practice, the meaning is simple: while processing an event, it may happen that we call emit again — directly or indirectly.

For example, you add an expense → a handler decides the amount is too large → it automatically adds a “fee” as a separate expense → adding the fee also triggers the “expense added” event. Boom: nested dispatch.

It’s important that such a scenario doesn’t turn the program into chaos. The snapshot approach already provides the foundation: each emit takes its own copy of listeners. But it’s worth clearly understanding what effects are possible and what traps remain.

Reentrancy example: nested emit

fun main() {
    val e = Event<Int>()

    e.subscribe { x ->
        if (x == 1) e.emit(2)
        println("A got $x")
    }
    e.subscribe { x -> println("B got $x") }

    e.emit(1)
}

One possible output will look like this (the line order depends on where you print):

A got 2
B got 2
A got 1
B got 1

And that’s normal: you really did run emit(2) inside emit(1). So the order becomes “nested”, like function calls. That’s exactly what “synchronous dispatch” means.

What snapshot guarantees under reentrancy

Snapshot guarantees that even if inside emit(1) a handler subscribes or unsubscribes, it won’t break the current iteration. Each emit works with its own copy.

What snapshot does not solve

Snapshot won’t save you from infinite recursion. If a handler calls emit again for any value without stopping, you’ll get a stack overflow. That’s not a bug in Event<T>, it’s the handler’s logic.

So within today’s lecture we fix a simple and clear contract: emit is synchronous and allows nested calls, and the responsibility to “not go infinite” lies with the handlers.

5. Building a reliable Event<T>

Now let’s carefully assemble the final version of Event<T> that you can use in an educational app. We’ll continue the idea of a console expense tracker: you type “coffee 4.5”, and the app prints “coffee again, hang in there”.

Event model: “expense added”

For simplicity, let’s make a small model:

data class ExpenseAdded(
    val id: Int,
    val title: String,
    val amount: Double
)

Final Event<T>

Let’s make subscribe return an unsubscribe token, and make repeated unsubscription safe (it won’t crash and won’t try to “remove twice”).

class Event<T> {
    private val listeners = mutableListOf<(T) -> Unit>()

    fun subscribe(listener: (T) -> Unit): () -> Unit {
        listeners += listener
        var active = true
        return {
            if (active) {
                listeners.remove(listener)
                active = false
            }
        }
    }

    fun emit(value: T) {
        val snapshot = listeners.toList()
        for (l in snapshot) {
            try {
                l(value)
            } catch (e: Exception) {
                println("Listener failed: ${e.message}")
            }
        }
    }
}

Here the snapshot is taken via toList(), which gives us a “snapshot” of listeners at the moment dispatch starts.

try/catch is placed around each listener so that one failure doesn’t cut off dispatch for everyone else.

Integrating the event into a “mini-tracker”

Let’s say we have a service that adds an expense and emits an event:

class ExpenseService {
    private var nextId = 1
    val expenseAdded = Event<ExpenseAdded>()

    fun addExpense(title: String, amount: Double) {
        val e = ExpenseAdded(nextId++, title, amount)
        expenseAdded.emit(e)
    }
}

Two independent listeners: audit and “notifications”

fun main() {
    val service = ExpenseService()

    service.expenseAdded.subscribe { e ->
        println("AUDIT: added #${e.id} '${e.title}'") // AUDIT: added #1 'Coffee'
    }

    service.expenseAdded.subscribe { e ->
        if (e.amount > 1000) error("Too big!")        // may crash
        println("UI: +${e.amount} for ${e.title}")    // UI: +4.5 for Coffee
    }

    service.addExpense("Coffee", 4.5)
    service.addExpense("Laptop", 2000.0)
    service.addExpense("Sandwich", 6.0)
}

The expected logic is: “Laptop” will crash the second handler, but the audit must keep working, and the third expense must also be processed.

Example possible output:

AUDIT: added #1 'Coffee'
UI: +4.5 for Coffee
AUDIT: added #2 'Laptop'
Listener failed: Too big!
AUDIT: added #3 'Sandwich'
UI: +6.0 for Sandwich

Notice the behavior: the exception did not “stick” to the entire emit; it is isolated within a single listener.

Diagram: what happens inside emit

Sometimes it’s useful to see not only the code but also a “process picture”. Let’s imagine dispatch as a conveyor:

flowchart TD
    A["emit(value) called"] --> B["snapshot = listeners.toList()"]
    B --> C["for listener in snapshot"]
    C --> D["try { listener(value) }"]
    D --> E["success → next listener"]
    D --> F["catch Exception → log the error"]
    F --> E
    E --> G["end of dispatch"]

The point of the diagram is one thing: we first freeze the listener list, and then iterate the copy, isolating each handler’s errors.

6. Common mistakes

Mistake #1: taking the snapshot not at the beginning of emit.
Sometimes you see an approach like “first I’ll call a couple of listeners, then I’ll make a copy”. This is almost guaranteed to lead to weird behavior: some listeners will be called from the old list, some from the new one. If you choose a snapshot strategy, the snapshot must be the first step in dispatch; otherwise you lose the main benefit: predictability.

Mistake #2: wrapping the entire emit in one try/catch.
Such code looks “reliable”, but it protects the wrong thing. When an exception happens, the first failing handler will break the loop, and the rest of the listeners won’t receive the event. The correct principle here is isolation: try/catch must wrap each handler so one failure doesn’t break the whole process.

Mistake #3: silently “swallowing” exceptions.
If you catch an exception and do nothing (not even printing a message), you’re creating a very expensive mystery for your future self: “why did one of the modules stop reacting?”. In educational code, println is enough; in more serious code, at least record the message and, if possible, the stack trace. The main thing is: don’t turn errors into invisible ghosts.

Mistake #4: expecting that unsubscribe inside emit will instantly cancel the call in the current dispatch.
With the snapshot approach, that’s not how it works: the current dispatch is already iterating a copy. So a handler may receive the event “one more time” within the current emit, even if it just unsubscribed. This is not a bug; it’s part of the contract. The upside is that the contract is simple and stable: subscription changes affect the next dispatches.

Mistake #5: accidentally creating infinite reentrancy.
If a handler calls emit again for any event without a stopping condition, you’ll get recursion and eventually a stack overflow. Snapshot doesn’t have to (and cannot) fix this: it’s a handler logic error. So for reentrant scenarios, always keep an “exit condition” in mind: a value check, a flag, a limit, or at least common sense.

1
Task
Kotlin SELF, level 57, lesson 3
Locked
One-time listener
One-time listener
1
Task
Kotlin SELF, level 57, lesson 3
Locked
Safe event broadcasting
Safe event broadcasting
1
Task
Kotlin SELF, level 57, lesson 3
Locked
Late subscription
Late subscription
1
Task
Kotlin SELF, level 57, lesson 3
Locked
Nested emit
Nested emit
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION