## Run each scenario in its own ThreadSanitizer-instrumented process. ## Success means no race was reported; a crash is not required to expose UB. import std/[atomics, locks, os] import ../src/namespaced_logging const iterations = 1_000 var start: Atomic[bool] var cloneLock: Lock var ready: Atomic[int] proc awaitStart() = while not start.load(): discard proc serviceWorker(service: LogService) {.thread.} = awaitStart() for i in 0 ..< iterations: let local = service.threadLocalRef() let logger = local.getLogger("worker") doAssert logger.isEnabled(lvlInfo) proc independentWorker() {.thread.} = awaitStart() var service = initLogService() service.setThreshold("worker", lvlInfo) for i in 0 ..< iterations: let local = service.threadLocalRef() let logger = local.getLogger("worker") doAssert logger.isEnabled(lvlInfo) type AppenderLifetimes = object created, destroyed: Atomic[int] TrackedAppenderObj = object of LogAppender lifetimes: ptr AppenderLifetimes TrackedAppender = ref TrackedAppenderObj proc `=destroy`(app: TrackedAppenderObj) = if app.lifetimes != nil: app.lifetimes.destroyed.atomicInc() `=destroy`(app.namespace) proc trackedAppender(lifetimes: ptr AppenderLifetimes): TrackedAppender = lifetimes.created.atomicInc() TrackedAppender(lifetimes: lifetimes) method clone(app: TrackedAppender): LogAppender {.gcsafe.} = trackedAppender(app.lifetimes) proc lifetimeWorker(source: ptr LogService) {.thread.} = # Borrow the original only until this thread owns a local service. The # readiness barrier lets the parent release it before we continue. let local = source[].threadLocalRef() ready.atomicInc() awaitStart() for i in 0 ..< iterations: var handle = local.service var copied = handle handle = LogService() var moved = move(copied) moved.setThreshold("worker", lvlInfo) let nested = moved.threadLocalRef() doAssert nested.getLogger("worker").isEnabled(lvlInfo) # Local caches and the last shared owner are destroyed on worker threads. proc reconfigureWorker(service: LogService) {.thread.} = var handle = service let local = service.threadLocalRef() awaitStart() for i in 0 ..< iterations: handle.setThreshold("worker", lvlInfo) local.ensureFreshness() doAssert local.getLogger("worker").isEnabled(lvlInfo) type CustomState = ref object value: int StatefulAppender = CustomLogAppender[CustomState] proc ignoreMessage(state: CustomState, msg: LogMessage) {.gcsafe, noconv.} = discard proc stateWorker(source: ptr StatefulAppender) {.thread.} = awaitStart() for i in 0 ..< iterations: var copied: StatefulAppender withLock cloneLock: copied = StatefulAppender(clone(source[])) doAssert copied.state.value == 42 proc makeFormatter(prefix: string): LogMessageFormatter = result = proc(msg: LogMessage): string {.gcsafe.} = prefix & msg.msg proc formatterWorker(source: ptr ConsoleLogAppender) {.thread.} = awaitStart() # This formatter only needs msg. Avoid clock/timezone initialization so # sanitizer diagnostics identify closure ownership rather than libc state. let msg = LogMessage(msg: "message") for i in 0 ..< iterations: var copied: ConsoleLogAppender withLock cloneLock: copied = ConsoleLogAppender(clone(source[])) doAssert copied.formatter(msg) == "prefix: message" proc main() = doAssert paramCount() == 1 initLock(cloneLock) defer: deinitLock(cloneLock) case paramStr(1) of "independent": var workers: array[4, Thread[void]] for i in 0 ..< workers.len: createThread(workers[i], independentWorker) start.store(true) joinThreads(workers) of "service": var service = initLogService() service.setThreshold("worker", lvlInfo) service.addAppender(initConsoleLogAppender()) var workers: array[4, Thread[LogService]] for i in 0 ..< workers.len: createThread(workers[i], serviceWorker, service) # No parent-side copying/configuration changes overlap the workers. # The parent retains service and all thread argument storage until join. start.store(true) joinThreads(workers) of "custom-state": var source = initCustomLogAppender(CustomState(value: 42), ignoreMessage) var workers: array[4, Thread[ptr StatefulAppender]] for i in 0 ..< workers.len: createThread(workers[i], stateWorker, addr source) start.store(true) joinThreads(workers) of "service-lifetime": var lifetimes: AppenderLifetimes var service = initLogService() block: let original = trackedAppender(addr lifetimes) service.addAppender(original) var workers: array[4, Thread[ptr LogService]] for i in 0 ..< workers.len: createThread(workers[i], lifetimeWorker, addr service) while ready.load() != workers.len: discard # Thread arguments contain only a borrowed pointer, so there are no hidden # owning handles in Thread.data keeping the control block alive here. service = LogService() start.store(true) joinThreads(workers) doAssert lifetimes.created.load() > workers.len doAssert lifetimes.destroyed.load() == lifetimes.created.load() of "service-reconfigure": var service = initLogService() service.addAppender(initConsoleLogAppender()) var workers: array[4, Thread[LogService]] for i in 0 ..< workers.len: createThread(workers[i], reconfigureWorker, service) start.store(true) joinThreads(workers) of "formatter": var source = initConsoleLogAppender() source.formatter = makeFormatter("prefix: ") var workers: array[4, Thread[ptr ConsoleLogAppender]] for i in 0 ..< workers.len: createThread(workers[i], formatterWorker, addr source) start.store(true) joinThreads(workers) else: raise newException(ValueError, "unknown thread safety scenario") # The pointer-based scenarios borrow immutable source appenders whose owner # outlives all workers. This isolates clone/destruction races from LogService # copying and from ordinary unsynchronized application-state mutation. main()