Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.airthings.lib.logging.PLATFORM_ANDROID
import com.airthings.lib.logging.ifAfter
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile

internal actual class PlatformFileInputOutputImpl : PlatformFileInputOutput {
private val writeLock = Any()
Expand All @@ -47,13 +48,12 @@ internal actual class PlatformFileInputOutputImpl : PlatformFileInputOutput {
contents: String,
) {
synchronized(writeLock) {
FileOutputStream(File(path), true).use {
val channel = it.channel
val size = channel.size()
RandomAccessFile(File(path), "rw").use { raf ->
val size = raf.length()
val relativePosition = position.relativeToSize(size)

channel.position(relativePosition)
it.write(contents.toByteArray())
raf.seek(relativePosition)
raf.write(contents.toByteArray())
}
}
}
Expand All @@ -70,8 +70,12 @@ internal actual class PlatformFileInputOutputImpl : PlatformFileInputOutput {
}

actual override suspend fun ensure(path: String) {
synchronized(writeLock) {
File(path).createNewFile()
val parentPath = File(path).parentFile.canonicalPath

if (mkdirs(parentPath)) {
synchronized(writeLock) {
File(path).createNewFile()
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import com.airthings.lib.logging.platform.PlatformFileInputOutputNotifier
import com.airthings.lib.logging.utc
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.LocalDateTime

/**
Expand Down Expand Up @@ -137,6 +139,7 @@ class JsonLoggerFacility(
},
)
private val currentLogFile = AtomicReference<String?>(null)
private val writeMutex = Mutex()

/**
* Returns the platform-dependent [PlatformDirectoryListing] instance.
Expand Down Expand Up @@ -236,26 +239,42 @@ class JsonLoggerFacility(
}

coroutineScope.launch {
val logFile = "$baseFolder${io.pathSeparator}${dateStamp(null)}.json"
val currentLogFileLocked = currentLogFile.value

// New JSON log files always contain an empty array ("[]") which is 2 bytes long.
val isEmpty = io.size(logFile) > 2L

if (currentLogFileLocked != logFile) {
if (currentLogFileLocked != null) {
notifier?.onLogFileClosed(currentLogFileLocked)
var closedPath: String? = null
var openedPath: String? = null

// Serialize size-check and write: two concurrent launches can otherwise both
// read the same pre-write size and both skip the comma separator. Notifier
// callbacks are user code and must run outside the lock — invoking them under
// a non-reentrant `Mutex` would deadlock if a notifier triggers another log().
writeMutex.withLock {
val logFile = "$baseFolder${io.pathSeparator}${dateStamp(null)}.json"
val currentLogFileLocked = currentLogFile.value

if (currentLogFileLocked != logFile) {
// ensure() can throw; mutate state only after it succeeds so a retry
// on the next log() sees the same `currentLogFile` and re-attempts.
io.ensure(logFile)
closedPath = currentLogFileLocked
currentLogFile.set(logFile)
openedPath = logFile
}
Comment thread
LaStrada marked this conversation as resolved.
io.ensure(logFile)

// New JSON log files start their life as an empty array ("[]").
io.append(logFile, "$ARRAY_OPEN$ARRAY_CLOSE")

currentLogFile.set(logFile)
notifier?.onLogFileOpened(logFile)
// A fresh JSON log file is "[]" (2 bytes). Anything longer means at least
// one entry already, so the new entry needs a comma separator. A file
// shorter than 2 bytes (size 0, or a stray "[" from a partial write or
// external truncation) is reseeded via write-at-position-0 — `append`
// would leave the stray byte in place and produce "[[]".
val size = io.size(logFile)
if (size < 2L) {
io.write(logFile, position = 0L, contents = "$ARRAY_OPEN$ARRAY_CLOSE")
}
action(logFile, if (size > 2L) "," else "")
}

action(logFile, if (isEmpty) "" else ",")
// Notifier callbacks are user code; isolate failures so a misbehaving observer
// can't tear down the log coroutine after the write has already succeeded.
closedPath?.let { path -> runCatching { notifier?.onLogFileClosed(path) } }
openedPath?.let { path -> runCatching { notifier?.onLogFileOpened(path) } }
}
}

Expand All @@ -273,13 +292,27 @@ class JsonLoggerFacility(
private const val CURLY_OPEN: Char = '{'
private const val CURLY_CLOSE: Char = '}'

private fun String.jsonEscape(): String = replace("\\", "\\\\")
.replace("/", "\\/")
.replace("\"", "\\\"")
.replace("\b", "\\b")
.replace("\r", "\\r")
.replace("\n", "\\n")
.replace("\t", "\\t")
private fun String.jsonEscape(): String = buildString(length) {
this@jsonEscape.forEach { c ->
when (c) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'/' -> append("\\/")
'\b' -> append("\\b")
'\u000C' -> append("\\f")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> if (c < '\u0020') {
// RFC 8259: every control character U+0000..U+001F must be escaped.
append("\\u")
append(c.code.toString(16).padStart(4, '0'))
} else {
append(c)
}
}
}
}

private fun String.jsonQuote(): String = "\"${jsonEscape()}\""

Expand Down Expand Up @@ -335,6 +368,8 @@ class JsonLoggerFacility(

if (!args.isNullOrEmpty()) {
append(COMMA)
append(ARGS_KEY.jsonQuote())
append(':')
append(args.jsonEntry())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.airthings.lib.logging.PLATFORM_JVM
import com.airthings.lib.logging.ifAfter
import java.io.File
import java.io.FileOutputStream
import java.io.RandomAccessFile

internal actual class PlatformFileInputOutputImpl : PlatformFileInputOutput {
private val writeLock = Any()
Expand All @@ -47,13 +48,15 @@ internal actual class PlatformFileInputOutputImpl : PlatformFileInputOutput {
contents: String,
) {
synchronized(writeLock) {
FileOutputStream(File(path), true).use {
val channel = it.channel
val size = channel.size()
// `FileOutputStream(file, append=true)` silently ignores `channel.position(...)`
// because append mode forces every write to the end of the file. Use
// `RandomAccessFile` so `position` is honored.
RandomAccessFile(File(path), "rw").use { raf ->
val size = raf.length()
val relativePosition = position.relativeToSize(size)

channel.position(relativePosition)
it.write(contents.toByteArray())
raf.seek(relativePosition)
raf.write(contents.toByteArray())
}
Comment thread
LaStrada marked this conversation as resolved.
}
}
Expand Down
Loading