Drain background writers before process exit

Serialize writer submission and shutdown, enqueue explicit stop messages after accepted records, and finish the final file batch before joining. Keep drained process-wide channel storage until exit to avoid the Nim 2.2.10 Channel.close mutex teardown defect.

Cover empty records, multiple destinations, joined producers, and concurrent shutdown. Include writer lifecycle sanitizer tests in nimble test and update usage documentation.

Validation: all 32 unit tests, eight writer exit scenarios, and writer lifecycle ThreadSanitizer tests pass. The full suite retains the known configuration refresh, timestamp, and default allocator failures.
This commit is contained in:
jdb committed 2026-09-28 15:00:18 -05:00
1 parent b2d0e9dade
commit d5e3b9c301
7 files changed
+241 -125

No files matched your search

+122 -91
View File
@@ -1,4 +1,4 @@
import std/[algorithm, atomics, exitprocs, json, locks, options, os, paths,
import std/[algorithm, atomics, exitprocs, json, locks, options, paths,
sequtils, strutils, tables, times]
import timeutils
import std/logging as stdlog
@@ -93,18 +93,21 @@ type
local: StatefulLogFormatterFunc
ConsoleMessage = object
stop: bool
msg: string
useStderr: bool
FileMessage = object
stop: bool
msg: string
absPath: Path
LogWriterThreadState[M] = object
initialized: Atomic[bool]
shutdown: Atomic[bool]
lock: Lock
initialized: bool
shutdown: bool
chan: Channel[M]
writerThread: Thread[void]
@@ -204,9 +207,8 @@ proc service*(ls: ThreadLocalLogService): LogService =
var consoleLogging {.global.}: LogWriterThreadState[ConsoleMessage]
var fileLogging {.global.}: LogWriterThreadState[FileMessage]
var loggingThreadInitLock {.global.}: Lock
initLock(loggingThreadInitLock)
initLock(consoleLogging.lock)
initLock(fileLogging.lock)
proc initLogMessage*(
@@ -263,33 +265,25 @@ proc defaultErrorHandlerFunc*(
stderr.flushFile()
except Exception: discard # we tried...
proc shutdownWriter[M](state: var LogWriterThreadState[M]) =
# Submission uses this same lock. The stop message follows all accepted
# records, and no producer can submit after shutdown begins.
withLock state.lock:
if state.shutdown: return
state.shutdown = true
if not state.initialized: return
state.chan.send(M(stop: true))
joinThread(state.writerThread)
# These queues have process lifetime and cannot be restarted. Keep the
# drained channel storage until process exit: Nim 2.2.10's Channel.close
# destroys its internal mutex while holding it (a sanitizer failure).
state.initialized = false
proc shutdownThreadedConsoleLogging() =
if not consoleLogging.initialized.load(): return
withLock loggingThreadInitLock:
consoleLogging.shutdown.store(true) # signal shutdown
# Send sentinel values to wake up the writer thread
try: consoleLogging.chan.send(ConsoleMessage(msg: "", useStderr: false))
except Exception: discard
joinThread(consoleLogging.writerThread)
consoleLogging.chan.close()
consoleLogging.initialized.store(false)
shutdownWriter(consoleLogging)
proc shutdownThreadedFileLogging() =
if not fileLogging.initialized.load(): return
fileLogging.shutdown.store(true) # signal shutdown
withLock loggingThreadInitLock:
try: fileLogging.chan.send(FileMessage(msg: "", absPath: Path("/")))
except Exception: discard
joinThread(fileLogging.writerThread)
fileLogging.chan.close()
fileLogging.initialized.store(false)
shutdownWriter(fileLogging)
proc ensureFreshness*(ls: var LogService) =
@@ -630,41 +624,34 @@ template fatal*[L: Logger or Option[Logger], M](l: L, err: ref Exception, msg: M
# -----------------------------------------------------------------------------
proc consoleWriterLoop() {.thread.} =
while not consoleLogging.shutdown.load():
var didSomething = false
let (hasData, cMsgObj) = consoleLogging.chan.tryRecv()
if hasData and cMsgObj.msg.len > 0: # Skip empty sentinel messages
try:
let output =
if cMsgObj.useStderr: stderr
else: stdout
output.write(cMsgObj.msg)
output.flushFile()
didSomething = true
except IOError:
discard
# Small delay if no work to prevent busy waiting
if not didSomething: sleep(100)
while true:
let message = consoleLogging.chan.recv()
if message.stop: break
try:
let output = if message.useStderr: stderr else: stdout
output.write(message.msg)
output.flushFile()
except IOError:
discard
proc fileWriterLoop() {.thread.} =
const bufLen = 128
var msgsByPath = newTable[Path, seq[FileMessage]]()
while not fileLogging.shutdown.load():
var didSomething = false
while true:
var msgBuf: array[bufLen, FileMessage]
var recvIdx = 0
msgBuf[0] = fileLogging.chan.recv()
var recvIdx = 1
var writeIdx = 0
var dataAvailable = true
var stopping = msgBuf[0].stop
while dataAvailable and recvIdx < bufLen:
# Fill our message buffer if we can
(dataAvailable, msgBuf[recvIdx]) = fileLogging.chan.tryRecv()
if dataAvailable: inc recvIdx
while not stopping and recvIdx < bufLen:
let (available, message) = fileLogging.chan.tryRecv()
if not available: break
msgBuf[recvIdx] = message
inc recvIdx
stopping = message.stop
# Organize messages by destination file
msgsByPath.clear()
@@ -672,10 +659,9 @@ proc fileWriterLoop() {.thread.} =
let fMsgObj = msgBuf[writeIdx]
inc writeIdx
if fMsgObj.msg.len > 0: # skip empty sentinel messages
if not fMsgObj.stop:
if not msgsByPath.contains(fMsgObj.absPath): msgsByPath[fMsgObj.absPath] = @[]
msgsByPath[fMsgObj.absPath].add(fMsgObj)
didSomething = true
# Write all messages in file order to optimize file open/flush/close
for path, msgs in pairs(msgsByPath):
@@ -691,34 +677,30 @@ proc fileWriterLoop() {.thread.} =
flushFile(f)
close(f)
# Wait a bit if we had no work to prevent busy waiting
if not didSomething: sleep(100)
# The final batch must be written before honoring its stop message.
if stopping: break
proc initThreadedConsoleLogging() =
if consoleLogging.initialized.load: return
withLock loggingThreadInitLock:
if consoleLogging.initialized.load: return
consoleLogging.chan.open()
consoleLogging.shutdown.store(false)
# Create writer thread with reference to the service
createThread(consoleLogging.writerThread, consoleWriterLoop)
consoleLogging.initialized.store(true)
proc sendConsoleMessage(message: ConsoleMessage) =
withLock consoleLogging.lock:
if consoleLogging.shutdown:
raise newException(IOError, "Console logging has shut down")
if not consoleLogging.initialized:
consoleLogging.chan.open()
createThread(consoleLogging.writerThread, consoleWriterLoop)
consoleLogging.initialized = true
consoleLogging.chan.send(message)
proc initThreadedFileLogging() =
if fileLogging.initialized.load: return
withLock loggingThreadInitLock:
if fileLogging.initialized.load: return
fileLogging.chan.open()
fileLogging.shutdown.store(false)
# Create writer thread with reference to the service
createThread(fileLogging.writerThread, fileWriterLoop)
fileLogging.initialized.store(true)
proc sendFileMessage(message: FileMessage) =
withLock fileLogging.lock:
if fileLogging.shutdown:
raise newException(IOError, "File logging has shut down")
if not fileLogging.initialized:
fileLogging.chan.open()
createThread(fileLogging.writerThread, fileWriterLoop)
fileLogging.initialized = true
fileLogging.chan.send(message)
# -----------------------------------------------------------------------------
@@ -872,11 +854,8 @@ proc appendLogMessageMultiThreaded(
ls: ThreadLocalLogService,
msg: LogMessage) {.gcsafe.} =
if not consoleLogging.initialized.load():
initThreadedConsoleLogging()
try:
consoleLogging.chan.send(ConsoleMessage(
sendConsoleMessage(ConsoleMessage(
msg: cla.formatter.format(msg),
useStderr: cla.useStderr))
except Exception:
@@ -961,11 +940,8 @@ proc appendLogMessageMultiThreaded(
ls: ThreadLocalLogService,
msg: LogMessage) {.gcsafe.} =
if not fileLogging.initialized.load():
initThreadedFileLogging()
try:
fileLogging.chan.send(FileMessage(
sendFileMessage(FileMessage(
msg: fla.formatter.format(msg),
absPath: fla.absPath))
except Exception: discard
@@ -1060,7 +1036,7 @@ addExitProc(proc() =
when isMainModule:
import std/[files, tempfiles, unittest]
import std/[files, os, tempfiles, unittest]
import ./namespaced_logging/testutil
suite "LogService Initialization":
@@ -1560,3 +1536,58 @@ when isMainModule:
lm[0].msg.contains("message at notice")
lm[1].msg.contains("message at warn")
lm[2].msg.contains("message at fatal")
type WriterShutdownProbe = object
path: Path
ready: Atomic[int]
start: Atomic[bool]
accepted: array[4, int]
proc submitDuringShutdown(args: tuple[probe: ptr WriterShutdownProbe, worker: int]) {.thread.} =
let probe = args.probe
let worker = args.worker
sendFileMessage(FileMessage(absPath: probe.path, msg: $worker & ":0"))
probe.accepted[worker] = 1
probe.ready.atomicInc()
while not probe.start.load(): discard
for i in 1 ..< 10_000:
try:
sendFileMessage(FileMessage(absPath: probe.path, msg: $worker & ":" & $i))
inc probe.accepted[worker]
except IOError:
break
suite "Background writer lifecycle":
test "shutdown drains accepted records while excluding further sends":
let directory = createTempDir("namespaced_logging_lifecycle_", "")
defer: removeDir(directory)
var probe = WriterShutdownProbe(path: Path(directory / "messages.log"))
var workers: array[4, Thread[tuple[probe: ptr WriterShutdownProbe, worker: int]]]
for i in 0 ..< workers.len:
createThread(workers[i], submitDuringShutdown, (addr probe, i))
while probe.ready.load() != workers.len: discard
probe.start.store(true)
shutdownThreadedFileLogging()
joinThreads(workers)
# Shutdown is permanent and repeatable; it must never reopen the channel.
shutdownThreadedFileLogging()
expect IOError:
sendFileMessage(FileMessage(absPath: probe.path, msg: "too late"))
var received: array[4, int]
for line in readFile($probe.path).splitLines():
if line.len == 0: continue
let parts = line.split(':')
require parts.len == 2
let worker = parseInt(parts[0])
require worker in 0 ..< workers.len
check parseInt(parts[1]) == received[worker]
inc received[worker]
check received == probe.accepted
test "console shutdown wakes an empty writer and rejects further sends":
sendConsoleMessage(ConsoleMessage(msg: ""))
shutdownThreadedConsoleLogging()
shutdownThreadedConsoleLogging()
expect IOError:
sendConsoleMessage(ConsoleMessage(msg: ""))