diff --git a/README.md b/README.md index a4cab61..3bb444e 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ - Story-driven logging primitives instead of flat logger calls - Single-event logging with `note(...)` and contextual logging with `newScroll(...)` -- Best-effort non-suspending variants with `flingNote(...)` (returns `Boolean` acceptance) and `looseSeal(...)` - Delivery hooks through `NoteSaver`, `ScrollSaver`, and `EntrySaver` - Scroll lifecycle enrichment through `Margin` @@ -62,7 +61,7 @@ Scribe.inscribe { } ) } -Scribe.hire() +Scribe.hire(channel = Channel(capacity = 256)) Scribe.note( tag = "payments", @@ -83,15 +82,17 @@ Scribe.inscribe { "environment" to JsonPrimitive("production"), ) } -Scribe.hire() +Scribe.hire(channel = Channel(capacity = 256)) val scroll = Scribe.newScroll(id = "checkout-42") -scroll.writeString("gateway", "stripe") -scroll.writeNumber("attempt", 1) -scroll.writeBoolean("retry", false) +scroll["gateway"] = JsonPrimitive("stripe") +scroll["attempt"] = JsonPrimitive(1) +scroll["retry"] = JsonPrimitive(false) scroll.seal(success = true) ``` +Each `seal(...)` call emits a separate `SealedScroll` snapshot, so sealing the same scroll more than once is allowed when you need multiple terminal records. + Choose the saver that matches your output flow: ```kotlin diff --git a/docs/api-concepts.md b/docs/api-concepts.md index d2c970e..711bc60 100644 --- a/docs/api-concepts.md +++ b/docs/api-concepts.md @@ -5,7 +5,7 @@ Scribe models logging with two event shapes: - `Note`: a single standalone event -- `SealedScroll`: the finalized result of a multi-step `Scroll` +- `SealedScroll`: a sealed snapshot result of a multi-step `Scroll` Both implement the sealed `Entry` interface, which is what `EntrySaver` receives. @@ -13,9 +13,11 @@ Both implement the sealed `Entry` interface, which is what `EntrySaver` receives - `note(...)`: suspending call for a single log entry - `newScroll(...)`: starts a contextual logging session -- `seal(...)`: finalizes a scroll and emits a `SealedScroll` +- `seal(...)`: snapshots the current scroll data and emits a `SealedScroll` +- `extend(scroll)`: copies missing keys from another scroll into this one +- `append(key, scroll)`: nests a scroll as a JSON object under the given key - `Margin`: hook for writing fields at open/close boundaries -- `hire(channel = ..., onSaver = ...)`: starts delivery over your channel configuration +- `hire(channel = ..., scope = ..., onSaver = ...)`: starts delivery over your channel configuration ## `Scribe` @@ -54,7 +56,40 @@ val phase = scroll["phase"] val removed = scroll.remove("retryable") ``` -`scroll.id` reads the generated/custom `scroll_id` field. +`scroll.id` reads the generated/custom `scroll_id` field: + +```kotlin +val scroll = Scribe.newScroll(id = "checkout-42") +println(scroll.id) // "checkout-42" +``` + +Calling `seal(...)` more than once is allowed. Each call emits a separate `SealedScroll` with the current `success` value and a snapshot of the data at that point. + +## `Scroll` Extensions + +Beyond direct map writes, `Scroll` has two extension functions: + +### `extend(scroll)` +Copies only missing keys from another scroll into this one: + +```kotlin +val base = Scribe.newScroll(id = "base") +base["gateway"] = JsonPrimitive("stripe") + +val checkout = Scribe.newScroll(id = "checkout-42") +checkout["attempt"] = JsonPrimitive(1) +checkout.extend(base) // only copies "gateway" if not already present +``` + +### `append(key, scroll)` +Nests another scroll as a `JsonObject` under the given key: + +```kotlin +val meta = mutableMapOf() +meta["item_count"] = JsonPrimitive(3) +checkout.append("cart", meta) +// Result: checkout["cart"] = {"item_count": 3} +``` ## `Margin` @@ -92,6 +127,17 @@ Scribe.hire( ) ``` +You can optionally provide a custom `CoroutineScope` to control the lifecycle of the delivery coroutine: + +```kotlin +val customScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + +Scribe.hire( + scope = customScope, + channel = Channel(capacity = 256), +) +``` + ## Event Shapes ```kotlin @@ -113,6 +159,20 @@ SealedScroll( ) ``` +## Urgency Levels + +`Urgency` is used by `Note` to indicate severity: + +```kotlin +enum class Urgency { + VERBOSE, + DEBUG, + INFO, + WARN, + ERROR +} +``` + ## Failure Handling ```kotlin diff --git a/docs/getting-started.md b/docs/getting-started.md index a21c9c9..1f79aaf 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -19,9 +19,6 @@ kotlin { Initialize once with one or more savers, then hire the runtime with a `Channel`. ```kotlin -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.channels.Channel - Scribe.inscribe { shelves = listOf(NoteSaver { note -> println("[${note.level}] ${note.tag}: ${note.message}") @@ -57,11 +54,20 @@ With the saver above, the log output looks like this: ## Track a Flow with `Scroll` `Scroll` is a mutable map (`MutableMap`) that you seal into one wide event. +Each `seal(...)` call emits a new `SealedScroll` using a snapshot of the scroll data at that moment. + +You can also merge other scrolls or nest them: ```kotlin -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonPrimitive +val base = Scribe.newScroll() +base["gateway"] = JsonPrimitive("stripe") +val checkout = Scribe.newScroll(id = "checkout-42") +checkout.extend(base) // copies missing keys from base +checkout.append("meta", mapOf("items" to JsonPrimitive(3))) +``` + +```kotlin val scroll = Scribe.newScroll(id = "checkout-42") scroll["gateway"] = JsonPrimitive("stripe") scroll["attempt"] = JsonPrimitive(1) diff --git a/docs/lifecycle-and-delivery.md b/docs/lifecycle-and-delivery.md index 67c9113..1515907 100644 --- a/docs/lifecycle-and-delivery.md +++ b/docs/lifecycle-and-delivery.md @@ -2,7 +2,18 @@ ## Delivery Pipeline -`Scribe` delivers entries through the `Channel` you provide to `hire(...)`. +`Scribe` delivers entries through the `Channel` you provide to `hire(...)`. The channel is disposable and transfers ownership to Scribe, which closes it on processor completion or `retire()`. Create a fresh channel for each `hire(...)` call. + +You can optionally provide a custom `CoroutineScope` to control the delivery coroutine lifecycle: + +```kotlin +val customScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + +Scribe.hire( + scope = customScope, + channel = Channel(capacity = 256), +) +``` ```kotlin Scribe.inscribe { @@ -27,10 +38,12 @@ Scribe.hire( Current emission calls are suspending: - `note(...)` sends a `Note` -- `seal(...)` sends a `SealedScroll` +- `seal(...)` snapshots the current `Scroll` data and sends a `SealedScroll` There are no separate best-effort APIs in this runtime shape. +Multiple calls to `seal(...)` on the same `Scroll` are intentional. Each call emits a separate `SealedScroll`, so a flow can record more than one terminal snapshot when that is useful. + ## Shared Context with `imprint` `imprint` adds fields to every new `Scroll` created by the same `Scribe`. @@ -80,7 +93,7 @@ Use `retire()` to stop intake and wait until queued delivery work is finished. Scribe.retire() ``` -After `retire()`, you can call `hire(...)` again (with a new channel) to restart runtime delivery. +After `retire()`, the previous channel is closed and cannot be reused. Call `hire(...)` with a new channel to restart runtime delivery. ## Uncaught Exceptions diff --git a/docs/openobserve-showcase.md b/docs/openobserve-showcase.md index 8762c45..f71a1fe 100644 --- a/docs/openobserve-showcase.md +++ b/docs/openobserve-showcase.md @@ -9,6 +9,8 @@ The app covers the current public runtime features of Scribe: - `note(...)` - `newScroll(...)` with generated and custom IDs - direct `Scroll` map writes (`scroll["field"] = ...`) +- `extend(scroll)` to copy missing keys from another scroll +- `append(key, scroll)` to nest a scroll as a JSON object - map read/remove operations - `seal(...)` with success and failure outcomes - `Margin` @@ -83,6 +85,8 @@ or: 7. Run `Overflow demo` and confirm a burst can be trimmed by `DROP_OLDEST` under pressure. 8. Run `Saver failure demo` and observe that `onSaver` reports the injected failure while delivery continues. 9. Compare `retire() (light queue)` with `retire() with backlog` in the in-app timeline. +10. Run `Extend scroll` to verify keys are copied only when missing from the target scroll. +11. Run `Append scroll` to validate nested JSON object creation in OpenObserve. ## Querying In OpenObserve diff --git a/scribe/src/androidMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.android.kt b/scribe/src/androidMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.android.kt index 553fe73..d63277f 100644 --- a/scribe/src/androidMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.android.kt +++ b/scribe/src/androidMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.android.kt @@ -3,7 +3,10 @@ package com.rafambn.scribe internal actual fun installUncaughtExceptionHandler(handler: (Throwable) -> Unit) { val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - handler(throwable) - previous?.uncaughtException(thread, throwable) + try { + handler(throwable) + } finally { + previous?.uncaughtException(thread, throwable) + } } } diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt index 25143bf..812c789 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scribe.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.launch @@ -28,10 +30,9 @@ object Scribe { /** * Initializes the singleton with immutable parameters. * - * This function can be called only once per process lifetime. + * This function can be called again after [retire] has been called. */ fun inscribe(block: Inscribe.() -> Unit) { - check(config == null) { "Scribe is already initialized and cannot be initialized again." } val dsl = Inscribe().apply(block) val configuredShelves = dsl.shelves require(configuredShelves.isNotEmpty()) { "At least one shelf is required." } @@ -44,6 +45,10 @@ object Scribe { /** * Starts the delivery runtime using previously initialized parameters. + * + * The provided [channel] becomes disposable and transfers ownership to Scribe. + * Scribe closes the channel when the processor completes or when [retire] is called. + * Create a fresh channel for each call to this method. */ fun hire( scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), @@ -63,8 +68,14 @@ object Scribe { is ScrollSaver if entry is SealedScroll -> saver.write(entry) is NoteSaver if entry is Note -> saver.write(entry) } + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { - onSaver?.invoke(saver, entry, e) + try { + onSaver?.invoke(saver, entry, e) + } catch (_: Throwable) { + // Ignore callback failures to keep delivery alive. + } } } } @@ -96,7 +107,13 @@ object Scribe { } /** - * Stops accepting entries and waits for queued events to finish delivery. + * Stops accepting entries, closes the delivery channel, and waits for queued events to finish delivery. + * + * The channel passed to [hire] is closed and must not be reused. + * After this call completes, you may call [hire] again with a fresh channel. + * + * If called from within the processor coroutine (e.g., from a saver), + * this function returns immediately without waiting to avoid deadlocks. */ suspend fun retire() { val queue = activeQueue ?: return @@ -106,12 +123,24 @@ object Scribe { val callerJob = currentCoroutineContext()[Job] if (runningProcessor != null && !isProcessorFamily(runningProcessor, callerJob)) { runningProcessor.join() - if (processorJob === runningProcessor) { - processorJob = null - } } } + /** + * Checks if the caller job is the processor itself or a descendant. + * Traverses from caller up through parents to handle any nesting depth. + */ + @OptIn(ExperimentalCoroutinesApi::class) + private fun isProcessorFamily(root: Job, target: Job?): Boolean { + if (target == null) return false + var current: Job? = target + while (current != null) { + if (current === root) return true + current = current.parent + } + return false + } + /** * Emits a [Note] and suspends until it is enqueued. * @@ -141,23 +170,4 @@ object Scribe { internal suspend fun enqueue(entry: Entry) { requireActiveQueue().send(entry) } - - private fun isProcessorFamily(root: Job, target: Job?): Boolean { - if (target == null) return false - if (target === root) return true - return containsDescendant(root, target) - } - - private fun containsDescendant(root: Job, target: Job): Boolean { - val queue = ArrayDeque() - queue.add(root) - while (queue.isNotEmpty()) { - val current = queue.removeFirst() - current.children.forEach { child -> - if (child === target) return true - queue.addLast(child) - } - } - return false - } } diff --git a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt index 8907f6c..08fdd76 100644 --- a/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt +++ b/scribe/src/commonMain/kotlin/com/rafambn/scribe/Scroll.kt @@ -17,10 +17,12 @@ val Scroll.id: String /** * Seals this scroll and suspends until its [SealedScroll] is enqueued. + * + * Every call emits a new [SealedScroll] with a snapshot of the current data. */ suspend fun Scroll.seal(success: Boolean = true): SealedScroll { Scribe.config?.margins?.footer(this) - val result = SealedScroll(success = success, data = this) + val result = SealedScroll(success = success, data = this.toMap()) Scribe.enqueue(result) return result } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt index b2c2a6c..7ac8493 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeConcurrencyAndScrollTest.kt @@ -36,7 +36,7 @@ class ScribeConcurrencyAndScrollTest { } @Test - fun scroll_double_seal_is_idempotent_and_emits_only_once() { + fun scroll_double_seal_emits_one_event_per_seal_call() { runSuspend { val shelf = RecordingShelf() val scribe = scribeWithScrollShelves(shelf) @@ -51,7 +51,6 @@ class ScribeConcurrencyAndScrollTest { assertEquals(false, first.success) assertEquals(JsonPrimitive("initial"), first.data["state"]) - // Current behavior emits one SealedScroll per seal call. assertEquals(true, second.success) assertEquals(JsonPrimitive("initial"), second.data["state"]) assertEquals(2, shelf.events.size) diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt index 3d183ed..b138494 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeContextMarginTest.kt @@ -23,8 +23,8 @@ class ScribeContextMarginTest { scribe.retire() val event = shelf.events.single() - assertEquals(JsonPrimitive("test-service"), event.data["service"]) - assertEquals(JsonPrimitive("test"), event.data["environment"]) + assertEquals(JsonPrimitive("mobile-app"), event.data["service"]) + assertEquals(JsonPrimitive("production"), event.data["environment"]) } } @@ -45,8 +45,8 @@ class ScribeContextMarginTest { assertNotNull(firstEvent) assertNotNull(secondEvent) - assertEquals(JsonPrimitive("test-region"), firstEvent.data["region"]) - assertEquals(JsonPrimitive("test-region"), secondEvent.data["region"]) + assertEquals(JsonPrimitive("us-east"), firstEvent.data["region"]) + assertEquals(JsonPrimitive("us-east"), secondEvent.data["region"]) } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt index a03b9e6..ca87e1b 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDataSerializationTest.kt @@ -86,7 +86,7 @@ class ScribeDataSerializationTest { } @Test - fun erase_after_seal_mutates_same_backing_map() { + fun erase_after_seal_does_not_mutate_sealed_event_snapshot() { runSuspend { val shelf = RecordingShelf() val scribe = scribeWithScrollShelves(shelf) @@ -99,7 +99,7 @@ class ScribeDataSerializationTest { scribe.retire() val event = shelf.events.single() - assertNull(event.data["key"]) + assertEquals(JsonPrimitive("value"), event.data["key"]) } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt index 7ba9e38..62ce260 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeDeliveryRetireTest.kt @@ -1,6 +1,7 @@ package com.rafambn.scribe import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel @@ -231,4 +232,46 @@ class ScribeDeliveryRetireTest { assertEquals("boom", errors.single().message) } } + + @Test + fun onSaverError_callback_failure_does_not_stop_delivery() { + runSuspend { + val failingSaver = EntrySaver { throw IllegalStateException("boom") } + val recordingSaver = RecordingEntrySaver() + val scribe = scribeWithSavers( + shelves = listOf(failingSaver, recordingSaver), + onSaver = { _, _, _ -> + throw IllegalStateException("callback-failed") + }, + ) + + scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 10L) + scribe.note(tag = "payments", message = "continued", level = Urgency.INFO, timestamp = 11L) + recordingSaver.awaitEvents(2) + scribe.retire() + + assertEquals(2, recordingSaver.events.size) + } + } + + @Test + fun saver_cancellation_is_not_reported_to_onSaver() { + runSuspend { + val reportedErrors = mutableListOf() + val cancelingSaver = EntrySaver { throw CancellationException("cancel-delivery") } + val scribe = scribeWithSavers( + shelves = listOf(cancelingSaver), + channel = Channel(capacity = 16), + onSaver = { _, _, error -> + reportedErrors += error + }, + ) + + scribe.note(tag = "payments", message = "started", level = Urgency.INFO, timestamp = 12L) + delay(50) + scribe.retire() + + assertTrue(reportedErrors.isEmpty()) + } + } } diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt index 16b832d..49bc9c6 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeScrollLifecycleTest.kt @@ -28,7 +28,7 @@ class ScribeScrollLifecycleTest { } @Test - fun seal_is_idempotent_and_writes_once() { + fun each_seal_call_emits_a_new_event() { runSuspend { val shelf = RecordingShelf() val scribe = scribeWithScrollShelves(shelf) diff --git a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt index ccffe3e..3a3db0f 100644 --- a/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt +++ b/scribe/src/commonTest/kotlin/com/rafambn/scribe/ScribeTestFixtures.kt @@ -1,6 +1,7 @@ package com.rafambn.scribe import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.runBlocking @@ -11,13 +12,6 @@ import kotlinx.serialization.json.JsonPrimitive internal val UUID_REGEX = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") -private val defaultTestImprint = mapOf( - "service" to JsonPrimitive("test-service"), - "environment" to JsonPrimitive("test"), - "region" to JsonPrimitive("test-region"), -) - -private var initialized = false private var activeDelegatedSavers: List> = emptyList() private var activeMargin: Margin? = null private var onSaverErrorCallback: (saver: Saver<*>, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> } @@ -40,20 +34,30 @@ private val delegatingEntrySaver = EntrySaver { entry -> is ScrollSaver if entry is SealedScroll -> saver.write(entry) is NoteSaver if entry is Note -> saver.write(entry) } + } catch (error: CancellationException) { + throw error } catch (error: Throwable) { - onSaverErrorCallback(saver, entry, error) + try { + onSaverErrorCallback(saver, entry, error) + } catch (_: Throwable) { + // Keep test delivery path alive when callback fails. + } } } } +private var isInitialized = false + private fun ensureScribeInitialized() { - if (initialized) return + if (isInitialized) { + runBlocking { Scribe.retire() } + } Scribe.inscribe { shelves = listOf(delegatingEntrySaver) - imprint = defaultTestImprint + imprint = emptyMap() margins = delegatingMargin } - initialized = true + isInitialized = true } internal fun scribeWithScrollShelves( @@ -64,7 +68,7 @@ internal fun scribeWithScrollShelves( margins: Margin? = null, ): Scribe { ensureScribeInitialized() - runBlocking { Scribe.retire() } + Scribe.config!!.imprint = imprint activeDelegatedSavers = shelves.toList() activeMargin = margins onSaverErrorCallback = onSaver @@ -74,12 +78,13 @@ internal fun scribeWithScrollShelves( internal fun scribeWithSavers( shelves: List>, + imprint: Map = emptyMap(), margins: Margin? = null, channel: Channel = Channel(capacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST), onSaver: (saver: Saver<*>, entry: Entry, error: Throwable) -> Unit = { _, _, _ -> }, ): Scribe { ensureScribeInitialized() - runBlocking { Scribe.retire() } + Scribe.config!!.imprint = imprint activeDelegatedSavers = shelves activeMargin = margins onSaverErrorCallback = onSaver diff --git a/scribe/src/iosMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.ios.kt b/scribe/src/iosMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.ios.kt index 998b0db..b604969 100644 --- a/scribe/src/iosMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.ios.kt +++ b/scribe/src/iosMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.ios.kt @@ -2,12 +2,12 @@ package com.rafambn.scribe @OptIn(kotlin.experimental.ExperimentalNativeApi::class) internal actual fun installUncaughtExceptionHandler(handler: (Throwable) -> Unit) { - val previous = setUnhandledExceptionHook { throwable -> - handler(throwable) - } - // Re-install, wrapping previous hook - setUnhandledExceptionHook { throwable -> - handler(throwable) - previous?.invoke(throwable) + var previous: ((Throwable) -> Unit)? = null + previous = setUnhandledExceptionHook { throwable -> + try { + handler(throwable) + } finally { + previous?.invoke(throwable) + } } } diff --git a/scribe/src/jvmMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.jvm.kt b/scribe/src/jvmMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.jvm.kt index 553fe73..d63277f 100644 --- a/scribe/src/jvmMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.jvm.kt +++ b/scribe/src/jvmMain/kotlin/com/rafambn/scribe/GlobalExceptionHandler.jvm.kt @@ -3,7 +3,10 @@ package com.rafambn.scribe internal actual fun installUncaughtExceptionHandler(handler: (Throwable) -> Unit) { val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> - handler(throwable) - previous?.uncaughtException(thread, throwable) + try { + handler(throwable) + } finally { + previous?.uncaughtException(thread, throwable) + } } } diff --git a/scribe/src/jvmTest/kotlin/com/rafambn/scribe/GlobalExceptionHandlerJvmTest.kt b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/GlobalExceptionHandlerJvmTest.kt index 6846e1f..c10a217 100644 --- a/scribe/src/jvmTest/kotlin/com/rafambn/scribe/GlobalExceptionHandlerJvmTest.kt +++ b/scribe/src/jvmTest/kotlin/com/rafambn/scribe/GlobalExceptionHandlerJvmTest.kt @@ -3,6 +3,7 @@ package com.rafambn.scribe import java.util.Collections import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class GlobalExceptionHandlerJvmTest { @Test @@ -33,4 +34,35 @@ class GlobalExceptionHandlerJvmTest { Thread.setDefaultUncaughtExceptionHandler(original) } } + + @Test + fun installUncaughtExceptionHandler_calls_previous_even_if_installed_handler_throws() { + val original = Thread.getDefaultUncaughtExceptionHandler() + val invocations = Collections.synchronizedList(mutableListOf()) + + Thread.setDefaultUncaughtExceptionHandler { _, throwable -> + invocations += "previous:${throwable.message}" + } + + try { + installUncaughtExceptionHandler { throwable -> + invocations += "installed:${throwable.message}" + throw IllegalStateException("installed-failed") + } + + val thread = Thread { + throw IllegalStateException("boom") + } + thread.start() + thread.join(2_000) + + assertEquals( + listOf("installed:boom", "previous:boom"), + invocations.toList(), + ) + assertTrue(!thread.isAlive) + } finally { + Thread.setDefaultUncaughtExceptionHandler(original) + } + } }