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:
1 parent
b2d0e9dade
commit
d5e3b9c301
7 files changed
+241
-125
No files matched your search
+122
-91
@@ -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: ""))
|
||||
Reference in new issue
Block a user