Inquirer EntertainmentGrace Lee reveals pregnancy with ‘miracle baby’ at 43Daily MaverickWHAT’S COOKING: A steak salad with baby potatoes, broccoli, green beans, tomatoes and two dressingsPunchDSS arraigns man over fake Tinubu letter to US PresidentThe Jerusalem PostChannel 14 reporter verbally assaulted, harassed while covering voting in HaifaBollywood HungamaSaif Ali Khan and Vidya Balan reunite for Ritesh Batra’s Long Years Ago; Mélanie Laurent joins castESPNTransfer rumors, news: Real Madrid eye Liverpool's Alexis Mac AllisterInquirerKidapawan Diocese mourns desecration of parish church20 MinutenVom Essenslieferanten zum Star-DJ: Bunt. kommt in die SchweizUOLIsrael define destino de Netanyahu na primeira eleição desde os ataques de HamasColliderForget 'Saving Private Ryan,' This World War II Movie Is a Masterpiece From Start to FinishZDF heuteEntdecken Sie das ZDF-NachrichtenstudioBusiness AMGoud onder druk door sterkere dollar in aanloop naar Fed-besluit
The Daily Newsstand · Free, Always
Tuesday, October 27, 2026

Таймаут вернул 124, а следующий запуск пропущен: какие фоновые утилиты держат flock

Translate

Задача из cron превысила лимит timeout 600, в логе код 124. Через минуту cron запускает её снова, и обёртка flock -n молча выходит: lock занят, «предыдущий запуск ещё идёт». Но timeout уже вернул 124: главный процесс задачи завершился, а её фоновые потомки могли остаться.

Я проверил в контейнере семь обычных способов, которыми задача оставляет что-то работать в фоне: setsid -f, nohup … &, start-stop-daemon --background, двойной fork на Python с закрытием дескрипторов и без, ssh-agent, gpg-agent. Каждый запускался из задачи, которая держит flock, и обрывался через timeout 2 и subprocess.run(timeout=2). Демона не убила ни одна обёртка. Будет ли пропущен следующий запуск, решает сама фоновая утилита: закрывает ли она унаследованные дескрипторы. gpg-agent и наивный двойной fork держат lock, ssh-agent и start-stop-daemon отпускают. Зато у тех, что отпускают, процессы копятся: пять запусков подряд дали пять живых ssh-agent. А оборванный запуск в обоих случаях заканчивается одинаково: 124 от timeout, TimeoutExpired от subprocess.run.

Стенд

Задача берёт lock на дескрипторе 9, уходит в фон одним из семи способов и ждёт 30 секунд. Сама она ждёт с закрытым дескриптором, так что после её смерти lock может держать только фоновый потомок:

exec 9>"$D/lock"; flock -n 9 || { echo "job: lock busy"; exit 1; }
case $M in
  setsid_f)  setsid -f sleep 30 </dev/null >/dev/null 2>&1 ;;
  nohup_bg)  nohup sleep 30 </dev/null >/dev/null 2>&1 & ;;
  ssd_bg)    start-stop-daemon --start --background --make-pidfile \
                 --pidfile "$D/ssd.pid" --exec /usr/bin/sleep -- 30 ;;
  py_dfork)  python3 dfork.py keep ;;     # двойной fork + setsid
  py_dfork_close) python3 dfork.py close ;;  # то же + os.closerange(3, 1024)
  ssh_agent) eval "$(ssh-agent -s)" >/dev/null ;;
  gpg_agent) export GNUPGHOME="$D/gh.$$"; mkdir -m 700 "$GNUPGHOME"
             gpg-agent --daemon >/dev/null 2>&1 ;;
esac
sleep 30 9>&-

Через две секунды после возврата обёртки скрипт находит в /proc новые процессы, смотрит, у кого среди открытых файлов есть lock, и пробует flock -n тем же файлом, как следующий запуск. У ssh-agent и gpg-agent каталог /proc/PID/fd закрыт для чтения: оба помечают себя как non-dumpable. Держателя я находил иначе: убивал выживших по одному и смотрел, после кого lock освободился. Каждая ячейка прогнана три раза, повторы совпали. Образ python:3.15 (Debian 13), coreutils 9.7, Python 3.15.1.

Почему вообще важен дескриптор. По man flock(2) блокировка принадлежит открытому описанию файла, копии после fork её разделяют, и снимается она, когда закроется последняя копия. Любой потомок, который унаследовал дескриптор 9 и не закрыл его, держит lock, даже если про lock ничего не знает.

Семь способов уйти в фон

способ

жив после таймаута

lock: timeout 2

lock: run(timeout=2)

кто держит

setsid -f sleep

да

занят 3/3

занят 3/3

sleep

nohup sleep &

только после run

свободен 3/3

занят 3/3

sleep

start-stop-daemon --background

да

свободен 3/3

свободен 3/3

никто

двойной fork без закрытия fd

да

занят 3/3

занят 3/3

python3

двойной fork + closerange

да

свободен 3/3

свободен 3/3

никто

ssh-agent

да

свободен 3/3

свободен 3/3

никто

gpg-agent --daemon

да

занят 3/3

занят 3/3

gpg-agent

Обёртки отработали одинаково во всех 42 прогонах: вернулись через 2,00 секунды с кодом 124 или TimeoutExpired. Ни одна не убила процесс, который ушёл в свою сессию.

Единственная строка, где обёртка что-то решает, — nohup. Он не создаёт новую сессию и даже не меняет группу, он только игнорирует SIGHUP. timeout без --foreground шлёт TERM всей группе процессов, и sleep под nohup умирает. subprocess.run(timeout=) шлёт SIGKILL одному PID, своему прямому ребёнку, поэтому тот же sleep выживает и держит lock. Это видно и по исходникам: в src/timeout.c сигнал уходит группе, в subprocess.run вызывается process.kill().

Во всех остальных строках результат определяет утилита. setsid -f, самодельный двойной fork и gpg-agent унаследованный дескриптор не трогают. start-stop-daemon --background, ssh-agent и двойной fork по образцу PEP 3143 (на стенде — закрыть дескрипторы 3–1023) дескриптор закрывают. Из документации timeout и subprocess этого не узнать, это свойство конкретной программы и её версии.

Пять запусков подряд

Свободный lock выглядит как хороший исход. Я запустил каждую задачу пять раз подряд под timeout 2, как cron с коротким интервалом, с общим lock-файлом:

способ

прошли

пропущены

живых фоновых в конце

lock после

setsid -f

1

4

1

занят

двойной fork без закрытия

1

4

1

занят

gpg-agent

1

4

1

занят

start-stop-daemon --background

5

0

1

свободен

двойной fork + closerange

5

0

5

свободен

ssh-agent

5

0

5

свободен

У одной причины два исхода. Потомок, который держит дескриптор, съедает следующие запуски, и cron видит это как «уже идёт». Потомок, который его закрыл, запускам не мешает, но после каждого таймаута остаётся новый процесс. Только start-stop-daemon не размножился, потому что не стартует второй экземпляр при живом PID из pidfile. Таймаут здесь ни при чём. Оборванный запуск во всех шести строках вернул 124, пропущенные вернули 1 от flock -n. По коду первого запуска не угадать, что будет дальше: пропуски или утечка процессов.

Что освободило lock

Флаги timeout тут не помогают. На синтетическом дереве из трёх потомков с тем же lock (обычный фоновый, после setsid, с trap '' TERM) я прогнал timeout 2, --foreground и -k 1 2: setsid-потомка не достал ни один вариант, lock оставался занят во всех 15 прогонах.

subprocess.run(timeout=2) убил только прямого ребёнка, Popen с os.killpg — всех, кроме setsid-потомка.

Без cgroup всё дерево закрыл один вариант. Обёртка объявляет себя subreaper через prctl(PR_SET_CHILD_SUBREAPER), и осиротевшие потомки переподвешиваются к ней вместо PID 1. После killpg она обходит /proc, собирает всех, кто числится её потомками, и шлёт им KILL:

PR_SET_CHILD_SUBREAPER = 36
ctypes.CDLL(None, use_errno=True).prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0)
p = subprocess.Popen(cmd, start_new_session=True)
try:
    p.wait(timeout=2)
except subprocess.TimeoutExpired:
    os.killpg(p.pid, signal.SIGTERM)
    ...                                   # ждём главный процесс до секунды, затем killpg KILL
    while left := descendants(os.getpid()):   # обход /proc по ppid
        for pid in left:
            os.kill(pid, signal.SIGKILL)
        ...                                   # пожать зомби через waitpid(-1, WNOHANG)

На синтетическом дереве это заняло 2,05 секунды вместо 2,00, setsid-потомок убит, lock свободен. На демонах из таблицы я subreaper не прогонял; механизм тот же (двойной fork отрывает их от родителя), но это только вывод из устройства, замера нет. Другой путь — cgroup, например systemd с KillMode=control-group: из группы процессов уходят через setsid, а cgroup процесс так не покидает. В моём контейнере /sys/fs/cgroup смонтирован только для чтения, дочернюю группу создать нельзя, поэтому cgroup.kill я не проверял.

Что из этого следует

Демон, оставленный задачей, переживает и timeout, и subprocess.run, и дальше всё зависит от того, закрыл ли он унаследованный дескриптор с lock. Закрыл — следующие запуски идут, но после каждого таймаута в системе на один процесс больше, если утилита сама не проверяет, что уже запущена, как start-stop-daemon с pidfile. Не закрыл — cron пропускает запуски, пока демон жив. Оборванный запуск в обоих случаях возвращает одно и то же: 124 или TimeoutExpired.

Что с этим делать. Проверьте, закрывает ли ваша фоновая утилита унаследованный дескриптор с lock: gpg-agent, setsid -f и самодельный двойной fork его сохраняют и держат lock, ssh-agent, start-stop-daemon и fork с закрытием дескрипторов — закрывают.

Закрыть дескриптор принудительно (9>&- для фонового потомка или flock -o) можно, только если задача переживёт одновременный второй запуск. На стенде с flock -o второй запуск стартовал, пока потомки первого ещё работали: после его старта они сделали 60 записей. Если фоновый процесс ушёл в свою сессию и должен умереть вместе с задачей, нужен subreaper или cgroup, обёртка с таймаутом этого не сделает.

Окружение: блоки с утилитами — образ python:3.15 (Debian 13), Python 3.15.1, GNU coreutils timeout 9.7, gpg-agent 2.4.7, start-stop-daemon 1.22.22, OpenSSH 10.0p1; синтетическое дерево — python:3.15-slim, Python 3.15.0, bash 5.2.37, util-linux flock 2.41.5. Мануал timeout на сайте GNU описывает 9.12, логику группы и -k я сверял по текущему src/timeout.c.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.