A previous refactor narrowed the TOML loader catches to IOException +

SerializationException, dropping the IllegalArgumentException branch that
readJsonOrNull already has. tomlkt doesn't route every decode failure
through SerializationException: a stale stats.toml (old schema, e.g. a
non-integer key in Map<Int, Int>) throws a raw NumberFormatException.

That uncaught exception killed the parallelMap worker for the affected
project, nulled its result slot, and filterNotNull() dropped it from the
project selection list — while sync, a different path, still showed it.

Add a readTomlOrNull helper mirroring readJsonOrNull that absorbs the full
set tomlkt can throw on bad input (SerializationException,
IllegalArgumentException incl. NumberFormatException, IllegalStateException
from parser errors, IOException), with an onError callback so callers keep
their site-specific logging. Schema-version checks run after decode, so they
can't rescue a decode that throws first.

Migrate every TOML read site to it: ProjectStatisticsCacheReader,
StatisticsDatasource, ProjectDataDatasource, ProjectsListComponent,
ReferenceIndexDatasource, WritingActivityDatasource, and
SceneMetadataDatasource (which previously had no error handling at all).
Stale caches are now treated as a miss and recalculated.

Add ReadTomlOrNullTest covering each exception family and a
ProjectStatisticsCacheReaderTest reproducing the original crash.
This commit is contained in:
Adam Brown 2026-06-06 22:43:04 -07:00
parent 59f5172c93
commit f779996fee
No known key found for this signature in database
12 changed files with 283 additions and 94 deletions

View file

@ -67,6 +67,40 @@ inline fun <reified T : Any> FileSystem.readToml(path: Path, toml: Toml, clazz:
}
}
/**
* Reads and decodes [T] from a TOML file, returning null on any read or decode
* failure rather than throwing. [onError] is invoked with the failure (default
* no-op) so callers can log site-specific context including the exception.
*
* tomlkt does not funnel every decode failure through [SerializationException]:
* stale or hand-edited files can throw [IllegalArgumentException] (numeric
* coercion via NumberFormatException, type-mismatch casts, bad booleans) or
* [IllegalStateException] (parser errors such as a malformed date-time). All are
* treated as a missing/unusable file.
*/
@Suppress("SwallowedException")
inline fun <reified T : Any> FileSystem.readTomlOrNull(
path: Path,
toml: Toml,
onError: (Throwable) -> Unit = {},
): T? {
return try {
readToml<T>(path, toml)
} catch (e: IOException) {
onError(e)
null
} catch (e: SerializationException) {
onError(e)
null
} catch (e: IllegalArgumentException) {
onError(e)
null
} catch (e: IllegalStateException) {
onError(e)
null
}
}
inline fun <reified T> FileSystem.writeToml(path: Path, toml: Toml, obj: T) {
write(path) {
val jsonStr = toml.encodeToString<T>(obj)

View file

@ -4,7 +4,7 @@ import com.arkivanov.decompose.ComponentContext
import com.arkivanov.decompose.value.Value
import com.arkivanov.decompose.value.getAndUpdate
import com.darkrockstudios.apps.hammer.*
import com.darkrockstudios.apps.hammer.base.http.readToml
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
import com.darkrockstudios.apps.hammer.common.components.ComponentToaster
import com.darkrockstudios.apps.hammer.common.components.ComponentToasterImpl
import com.darkrockstudios.apps.hammer.common.components.SavableComponent
@ -35,7 +35,6 @@ import io.github.aakira.napier.Napier
import korlibs.datastructure.iterators.parallelMap
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.first
import kotlinx.serialization.SerializationException
import net.peanuuutz.tomlkt.Toml
import okio.FileSystem
import okio.IOException
@ -164,13 +163,10 @@ class ProjectsListComponent(
loadProjectsJob = scope.launch {
val projects = projectsRepository.getProjects(projectsDir)
val projectData = projects.parallelMap { projectDef ->
// The project can be deleted concurrently (e.g. another window, or a refresh
// racing a delete) between listing it and reading its metadata. loadMetadata
// then tries to recreate the file in a directory that no longer exists and
// throws - skip the vanished project rather than failing the whole load.
// The project can be deleted concurrently
val metadata = try {
projectMetadataDatasource.loadMetadata(projectDef)
} catch (e: IOException) {
} catch (_: IOException) {
null
}
if (metadata != null) {
@ -181,7 +177,7 @@ class ProjectsListComponent(
totalWords = statisticsCacheReader.loadTotalWords(projectDef),
)
} else {
Napier.w { "Failed to load metadata for project: ${projectDef.name}" }
Napier.d { "Failed to load metadata for project: ${projectDef.name}" }
null
}
}.filterNotNull().sortedByDescending { it.metadata.info.lastAccessed }
@ -200,15 +196,9 @@ class ProjectsListComponent(
*/
private fun loadStoredProjectData(projectDef: ProjectDef): StoredData {
val path = projectDef.path.toOkioPath() / ProjectDataDatasource.FILENAME
return try {
fileSystem.readToml<StoredProjectData>(path, toml).data
} catch (e: IOException) {
Napier.w("Failed to read stored project data for ${projectDef.name}, using defaults", e)
StoredData()
} catch (e: SerializationException) {
Napier.w("Failed to read stored project data for ${projectDef.name}, using defaults", e)
StoredData()
}
return fileSystem.readTomlOrNull<StoredProjectData>(path, toml) { e ->
Napier.d("Failed to read stored project data for ${projectDef.name}, using defaults", e)
}?.data ?: StoredData()
}
private fun updateLastAccessed(projectDef: ProjectDef) {

View file

@ -1,7 +1,7 @@
package com.darkrockstudios.apps.hammer.common.data.projectdata
import com.darkrockstudios.apps.hammer.base.http.projectdata.ProjectData
import com.darkrockstudios.apps.hammer.base.http.readToml
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
import com.darkrockstudios.apps.hammer.base.http.writeToml
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
import com.darkrockstudios.apps.hammer.common.data.ProjectScoped
@ -13,10 +13,8 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import net.peanuuutz.tomlkt.Toml
import okio.FileSystem
import okio.IOException
import okio.Path
/**
@ -38,15 +36,9 @@ class ProjectDataDatasource(
suspend fun load(): StoredProjectData = withContext(dispatcherIo) {
val path = getPath()
if (!fileSystem.exists(path)) return@withContext StoredProjectData()
try {
fileSystem.readToml<StoredProjectData>(path, toml)
} catch (e: IOException) {
fileSystem.readTomlOrNull<StoredProjectData>(path, toml) { e ->
Napier.e("Failed to load project_data.toml: $path", e)
StoredProjectData()
} catch (e: SerializationException) {
Napier.e("Failed to load project_data.toml: $path", e)
StoredProjectData()
}
} ?: StoredProjectData()
}
suspend fun save(stored: StoredProjectData): Unit = withContext(dispatcherIo) {
@ -77,10 +69,7 @@ suspend fun loadStoredProjectData(
toml: Toml,
): StoredProjectData = withContext(Dispatchers.IO) {
val path = projectDef.path.toOkioPath() / ProjectDataDatasource.FILENAME
try {
fileSystem.readToml<StoredProjectData>(path, toml)
} catch (e: Exception) {
fileSystem.readTomlOrNull<StoredProjectData>(path, toml) { e ->
Napier.d("No project_data.toml at $path (${e.message})")
StoredProjectData()
}
} ?: StoredProjectData()
}

View file

@ -1,6 +1,7 @@
package com.darkrockstudios.apps.hammer.common.data.projectmetadata
import com.darkrockstudios.apps.hammer.base.ProjectId
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.Info
import com.darkrockstudios.apps.hammer.common.components.storyeditor.metadata.ProjectMetadata
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
@ -8,11 +9,9 @@ import com.darkrockstudios.apps.hammer.common.fileio.HPath
import com.darkrockstudios.apps.hammer.common.fileio.okio.toHPath
import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath
import io.github.aakira.napier.Napier
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import net.peanuuutz.tomlkt.Toml
import okio.FileSystem
import okio.IOException
import kotlin.time.Clock
class ProjectMetadataDatasource(
@ -26,21 +25,14 @@ class ProjectMetadataDatasource(
fun loadMetadata(projectDef: ProjectDef): ProjectMetadata {
val path = getMetadataPath(projectDef).toOkioPath()
val metadata = try {
val metadataText = fileSystem.read(path) {
readUtf8()
}
toml.decodeFromString(metadataText)
} catch (e: IOException) {
Napier.e("Failed to load project metadata: ${path.toHPath().path}")
// Delete any old corrupt file if we got here
return fileSystem.readTomlOrNull<ProjectMetadata>(path, toml) { e ->
Napier.e("Failed to load project metadata: ${path.toHPath().path}", e)
} ?: run {
// Missing or corrupt (tomlkt throws beyond SerializationException): drop the
// bad file and start fresh so migrators re-run instead of crashing the load.
fileSystem.delete(path, false)
createNewMetadata(projectDef)
}
return metadata
}
fun saveMetadata(metadata: ProjectMetadata, projectDef: ProjectDef) {

View file

@ -1,12 +1,10 @@
package com.darkrockstudios.apps.hammer.common.data.projectstatistics
import com.darkrockstudios.apps.hammer.base.http.readToml
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
import io.github.aakira.napier.Napier
import kotlinx.serialization.SerializationException
import net.peanuuutz.tomlkt.Toml
import okio.FileSystem
import okio.IOException
/** Reads the cached stats file directly, without opening a ProjectDefScope. */
class ProjectStatisticsCacheReader(
@ -18,15 +16,9 @@ class ProjectStatisticsCacheReader(
val file = StatisticsCachePaths.statsFile(projectDef)
if (!fileSystem.exists(file)) return null
val stats = try {
fileSystem.readToml<ProjectStatistics>(file, toml)
} catch (e: IOException) {
val stats = fileSystem.readTomlOrNull<ProjectStatistics>(file, toml) { e ->
Napier.d("Failed to read statistics cache for ${projectDef.name}", e)
return null
} catch (e: SerializationException) {
Napier.d("Failed to read statistics cache for ${projectDef.name}", e)
return null
}
} ?: return null
if (stats.schemaVersion != ProjectStatistics.CURRENT_SCHEMA_VERSION) return null
return stats

View file

@ -1,6 +1,6 @@
package com.darkrockstudios.apps.hammer.common.data.projectstatistics
import com.darkrockstudios.apps.hammer.base.http.readToml
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
import com.darkrockstudios.apps.hammer.base.http.writeToml
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
import com.darkrockstudios.apps.hammer.common.data.ProjectScoped
@ -8,10 +8,8 @@ import com.darkrockstudios.apps.hammer.common.dependencyinjection.ProjectDefScop
import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectIoDispatcher
import io.github.aakira.napier.Napier
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import net.peanuuutz.tomlkt.Toml
import okio.FileSystem
import okio.IOException
class StatisticsDatasource(
private val fileSystem: FileSystem,
@ -25,14 +23,8 @@ class StatisticsDatasource(
suspend fun loadStatistics(): ProjectStatistics? = withContext(dispatcherIo) {
val file = StatisticsCachePaths.statsFile(projectDef)
return@withContext if (fileSystem.exists(file)) {
try {
fileSystem.readToml(file, toml)
} catch (e: IOException) {
fileSystem.readTomlOrNull<ProjectStatistics>(file, toml) { e ->
Napier.e("Failed to load statistics cache", e)
null
} catch (e: SerializationException) {
Napier.e("Failed to load statistics cache", e)
null
}
} else {
null

View file

@ -1,6 +1,6 @@
package com.darkrockstudios.apps.hammer.common.data.references
import com.darkrockstudios.apps.hammer.base.http.readToml
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
import com.darkrockstudios.apps.hammer.base.http.writeToml
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
import com.darkrockstudios.apps.hammer.common.data.ProjectScoped
@ -9,10 +9,8 @@ import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectIoDispat
import com.darkrockstudios.apps.hammer.common.getCacheDirectory
import io.github.aakira.napier.Napier
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import net.peanuuutz.tomlkt.Toml
import okio.FileSystem
import okio.IOException
import okio.Path
import okio.Path.Companion.toPath
@ -28,14 +26,8 @@ class ReferenceIndexDatasource(
suspend fun loadIndex(): ReferenceIndex? = withContext(dispatcherIo) {
val file = getIndexPath()
return@withContext if (fileSystem.exists(file)) {
try {
fileSystem.readToml(file, toml)
} catch (e: IOException) {
fileSystem.readTomlOrNull<ReferenceIndex>(file, toml) { e ->
Napier.e("Failed to load reference index cache", e)
null
} catch (e: SerializationException) {
Napier.e("Failed to load reference index cache", e)
null
}
} else {
null

View file

@ -1,6 +1,6 @@
package com.darkrockstudios.apps.hammer.common.data.sceneeditorrepository.scenemetadata
import com.darkrockstudios.apps.hammer.base.http.readToml
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
import com.darkrockstudios.apps.hammer.base.http.writeToml
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
import com.darkrockstudios.apps.hammer.common.data.ProjectScoped
@ -27,7 +27,9 @@ class SceneMetadataDatasource(
suspend fun loadMetadata(sceneId: Int): SceneMetadata? = withContext(dispatcherIo) {
val file = getMetadataPath(sceneId).toOkioPath()
return@withContext if (fileSystem.exists(file)) {
fileSystem.readToml(file, toml)
fileSystem.readTomlOrNull<SceneMetadata>(file, toml) { e ->
Napier.e("Failed to load scene metadata for SceneId: $sceneId", e)
}
} else {
null
}

View file

@ -1,6 +1,6 @@
package com.darkrockstudios.apps.hammer.common.data.writingactivity
import com.darkrockstudios.apps.hammer.base.http.readToml
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
import com.darkrockstudios.apps.hammer.base.http.writeToml
import com.darkrockstudios.apps.hammer.base.http.writingactivity.DeviceLog
import com.darkrockstudios.apps.hammer.common.data.ProjectDef
@ -11,10 +11,8 @@ import com.darkrockstudios.apps.hammer.common.dependencyinjection.injectIoDispat
import com.darkrockstudios.apps.hammer.common.fileio.okio.toOkioPath
import io.github.aakira.napier.Napier
import kotlinx.coroutines.withContext
import kotlinx.serialization.SerializationException
import net.peanuuutz.tomlkt.Toml
import okio.FileSystem
import okio.IOException
import okio.Path
/**
@ -44,14 +42,8 @@ class WritingActivityDatasource(
suspend fun loadDeviceLog(deviceId: String): DeviceLog? = withContext(dispatcherIo) {
val path = getDeviceLogPath(deviceId)
if (!fileSystem.exists(path)) return@withContext null
try {
fileSystem.readToml(path, toml)
} catch (e: IOException) {
fileSystem.readTomlOrNull<DeviceLog>(path, toml) { e ->
Napier.e("Failed to load writing activity log: $path", e)
null
} catch (e: SerializationException) {
Napier.e("Failed to load writing activity log: $path", e)
null
}
}
@ -62,11 +54,8 @@ class WritingActivityDatasource(
.filter { it.name.endsWith(FILE_SUFFIX) }
.mapNotNull { path ->
val deviceId = path.name.removeSuffix(FILE_SUFFIX)
val log: DeviceLog? = try {
fileSystem.readToml(path, toml)
} catch (e: Exception) {
val log = fileSystem.readTomlOrNull<DeviceLog>(path, toml) { e ->
Napier.e("Failed to load writing activity log: $path", e)
null
}
log?.let { deviceId to it }
}.toMap()

View file

@ -0,0 +1,147 @@
package base.http
import com.darkrockstudios.apps.hammer.base.http.readTomlOrNull
import com.darkrockstudios.apps.hammer.base.http.writeToml
import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer
import kotlinx.serialization.Serializable
import okio.Path.Companion.toPath
import okio.fakefilesystem.FakeFileSystem
import org.junit.jupiter.api.Test
import kotlin.test.*
/**
* tomlkt does not route every malformed-input failure through SerializationException.
* These pin the full set [readTomlOrNull] must absorb so a stale or hand-edited cache
* file can never crash a caller: numeric coercion (NumberFormatException), bad booleans
* and type-mismatch casts (IllegalArgumentException), parser errors (IllegalStateException),
* tomlkt decode errors (SerializationException), and missing/unreadable files (IOException).
*/
class ReadTomlOrNullTest {
@Serializable
private data class Sample(
val count: Int,
val enabled: Boolean = false,
val byChapter: Map<Int, Int> = emptyMap(),
)
private val toml = createTomlSerializer()
private val fileSystem = FakeFileSystem()
private val path = "/sample.toml".toPath()
private fun write(content: String) {
fileSystem.write(path) { writeUtf8(content.trimIndent()) }
}
@Test
fun `valid toml decodes and does not report an error`() {
var errored = false
fileSystem.writeToml(path, toml, Sample(count = 3, enabled = true))
val result = fileSystem.readTomlOrNull<Sample>(path, toml) { errored = true }
assertNotNull(result)
assertEquals(3, result.count)
assertTrue(result.enabled)
assertFalse(errored)
}
@Test
fun `missing file returns null and reports the error`() {
var reported: Throwable? = null
val result = fileSystem.readTomlOrNull<Sample>(path, toml) { reported = it }
assertNull(result)
assertNotNull(reported)
}
@Test
fun `non-numeric int value returns null`() {
write("""count = "Title"""")
val reported = capture()
assertNull(reported.result)
assertNotNull(reported.error) // NumberFormatException
}
@Test
fun `non-numeric map key returns null`() {
// The production crash: a Map<Int, Int> with a non-integer key.
write(
"""
count = 0
[byChapter]
Title = 5
"""
)
val reported = capture()
assertNull(reported.result)
assertNotNull(reported.error) // NumberFormatException
}
@Test
fun `non-boolean value returns null`() {
write(
"""
count = 0
enabled = "maybe"
"""
)
val reported = capture()
assertNull(reported.result)
assertNotNull(reported.error) // IllegalArgumentException from requireNotNull
}
@Test
fun `type mismatch returns null`() {
// count is an Int field but the file holds a table.
write(
"""
[count]
nested = 1
"""
)
val reported = capture()
assertNull(reported.result)
assertNotNull(reported.error)
}
@Test
fun `malformed date-time literal returns null`() {
// An unquoted, invalid date-time token fails in tomlkt's parser with
// IllegalStateException rather than SerializationException.
write("count = 2020-99-99T25:61:61Z")
val reported = capture()
assertNull(reported.result)
assertNotNull(reported.error)
}
@Test
fun `malformed syntax returns null`() {
write("count = = 3")
val reported = capture()
assertNull(reported.result)
assertNotNull(reported.error) // SerializationException (UnexpectedTokenException)
}
private class Captured(val result: Sample?, val error: Throwable?)
private fun capture(): Captured {
var error: Throwable? = null
val result = fileSystem.readTomlOrNull<Sample>(path, toml) { error = it }
return Captured(result, error)
}
}

View file

@ -56,6 +56,28 @@ class ProjectMetadataDatasourceTest : BaseTest() {
assertEquals(expectedMetadata, metadata)
}
@Test
fun `Corrupt project metadata recovers instead of throwing`() {
createProject(fileSystem, PROJECT_1_NAME)
val path = projectMetadataDatasource.getMetadataPath(getProject1Def()).toOkioPath()
// dataVersion is an Int; a non-numeric value throws NumberFormatException on decode,
// which is not a SerializationException. Loading must recover, not crash the project load.
fileSystem.write(path) {
writeUtf8(
"""
[info]
created = "2022-12-30T07:08:02.691261600Z"
dataVersion = "not-a-number"
""".trimIndent()
)
}
val metadata = projectMetadataDatasource.loadMetadata(getProject1Def())
// Recreated fresh (dataVersion 0 so migrators re-run) rather than throwing.
assertEquals(0, metadata.info.dataVersion)
}
@Test
fun `Get Project Metadata Path`() {
createProject(fileSystem, PROJECT_1_NAME)

View file

@ -0,0 +1,48 @@
package repositories.projectstatistics
import com.darkrockstudios.apps.hammer.common.data.projectstatistics.ProjectStatisticsCacheReader
import com.darkrockstudios.apps.hammer.common.data.projectstatistics.StatisticsCachePaths
import com.darkrockstudios.apps.hammer.common.dependencyinjection.createTomlSerializer
import getProject1Def
import okio.fakefilesystem.FakeFileSystem
import org.junit.jupiter.api.Test
import kotlin.test.assertNull
/**
* A stale or hand-edited stats cache can hold values that tomlkt fails to coerce
* (e.g. a non-integer key in a `Map<Int, Int>`), which surfaces as a
* NumberFormatException - an IllegalArgumentException, not a SerializationException.
* The reader must treat that as a cache miss rather than letting it escape and
* crash the project-list load.
*/
class ProjectStatisticsCacheReaderTest {
@Test
fun `malformed stats cache returns null instead of throwing`() {
val fileSystem = FakeFileSystem()
val toml = createTomlSerializer()
val projectDef = getProject1Def()
val statsFile = StatisticsCachePaths.statsFile(projectDef)
fileSystem.createDirectories(statsFile.parent!!)
fileSystem.write(statsFile) {
// "Title" is not a valid Int key for wordsByChapter: Map<Int, Int>.
writeUtf8(
"""
numberOfScenes = 0
totalWords = 0
lastCalculated = "1970-01-01T00:00:00Z"
[wordsByChapter]
Title = 5
[encyclopediaEntriesByType]
""".trimIndent()
)
}
val reader = ProjectStatisticsCacheReader(fileSystem, toml)
assertNull(reader.loadStatistics(projectDef))
}
}