kitty-logger 0.2.0.dev2__tar.gz → 0.2.0.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {kitty_logger-0.2.0.dev2/src/kitty_logger.egg-info → kitty_logger-0.2.0.dev3}/PKG-INFO +7 -5
  2. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/README.md +6 -4
  3. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/pyproject.toml +1 -1
  4. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger/__init__.py +1 -1
  5. kitty_logger-0.2.0.dev3/src/kitty_logger/_server.py +153 -0
  6. kitty_logger-0.2.0.dev3/src/kitty_logger/_server_main.py +129 -0
  7. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger/_setup.py +158 -100
  8. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3/src/kitty_logger.egg-info}/PKG-INFO +7 -5
  9. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger.egg-info/SOURCES.txt +1 -0
  10. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/tests/test_kitty_logger.py +0 -44
  11. kitty_logger-0.2.0.dev2/src/kitty_logger/_server.py +0 -228
  12. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/LICENSE +0 -0
  13. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/setup.cfg +0 -0
  14. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger/_client.py +0 -0
  15. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger/_env.py +0 -0
  16. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger/_formatters.py +0 -0
  17. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger.egg-info/dependency_links.txt +0 -0
  18. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger.egg-info/requires.txt +0 -0
  19. {kitty_logger-0.2.0.dev2 → kitty_logger-0.2.0.dev3}/src/kitty_logger.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitty_logger
3
- Version: 0.2.0.dev2
3
+ Version: 0.2.0.dev3
4
4
  Summary: Cross-process logging via a dedicated log server process and SocketHandler.
5
5
  Author: Kitty
6
6
  License: MIT
@@ -55,13 +55,15 @@ if __name__ == "__main__":
55
55
  ## API
56
56
 
57
57
  - `setup_logging(log_file=None, level=logging.INFO, host="127.0.0.1", port=0, stream=True, console_fmt=..., file_fmt=..., datefmt=None) -> (host, port)`
58
- 启动日志服务子进程(始终使用 `spawn`)。幂等。已通过 `atexit` 注册清理。
59
- `port=0` 让操作系统挑选空闲端口;返回真实绑定到的 `(host, port)`。
58
+ 通过 `subprocess.Popen` 启动一个独立的日志服务子进程(不依赖
59
+ `multiprocessing`,因此**不会回头执行用户主脚本的任何顶层代码**)。
60
+ 幂等。已通过 `atexit` 注册清理。`port=0` 让操作系统挑选空闲端口;返回
61
+ 真实绑定到的 `(host, port)`。
60
62
  **`host` 必须是 loopback**——绑定非 loopback 地址会直接 `ValueError`。
61
63
  同时把主进程内 `logging.getLogger("kitty")` 这一个 logger 初始化好——挂上
62
64
  `SocketHandler`、设置 level、关闭 `propagate`。**不动 root logger**。
63
- `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op,仅返回
64
- 从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
65
+ 在用户用 `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op
66
+ 仅返回从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
65
67
  `if __name__ == "__main__":` 包裹。
66
68
  - `getLogger(name=None) -> logging.Logger`
67
69
  返回挂在 `kitty` 命名空间下的 logger:`getLogger("foo")` 实际拿到的是
@@ -42,13 +42,15 @@ if __name__ == "__main__":
42
42
  ## API
43
43
 
44
44
  - `setup_logging(log_file=None, level=logging.INFO, host="127.0.0.1", port=0, stream=True, console_fmt=..., file_fmt=..., datefmt=None) -> (host, port)`
45
- 启动日志服务子进程(始终使用 `spawn`)。幂等。已通过 `atexit` 注册清理。
46
- `port=0` 让操作系统挑选空闲端口;返回真实绑定到的 `(host, port)`。
45
+ 通过 `subprocess.Popen` 启动一个独立的日志服务子进程(不依赖
46
+ `multiprocessing`,因此**不会回头执行用户主脚本的任何顶层代码**)。
47
+ 幂等。已通过 `atexit` 注册清理。`port=0` 让操作系统挑选空闲端口;返回
48
+ 真实绑定到的 `(host, port)`。
47
49
  **`host` 必须是 loopback**——绑定非 loopback 地址会直接 `ValueError`。
48
50
  同时把主进程内 `logging.getLogger("kitty")` 这一个 logger 初始化好——挂上
49
51
  `SocketHandler`、设置 level、关闭 `propagate`。**不动 root logger**。
50
- `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op,仅返回
51
- 从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
52
+ 在用户用 `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op
53
+ 仅返回从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
52
54
  `if __name__ == "__main__":` 包裹。
53
55
  - `getLogger(name=None) -> logging.Logger`
54
56
  返回挂在 `kitty` 命名空间下的 logger:`getLogger("foo")` 实际拿到的是
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kitty_logger"
7
- version = "0.2.0.dev2"
7
+ version = "0.2.0.dev3"
8
8
  description = "Cross-process logging via a dedicated log server process and SocketHandler."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
@@ -33,4 +33,4 @@ from ._client import getLogger
33
33
  from ._setup import setup_logging, shutdown_logging
34
34
 
35
35
  __all__ = ["setup_logging", "shutdown_logging", "getLogger"]
36
- __version__ = "0.2.0.dev2"
36
+ __version__ = "0.2.0.dev3"
@@ -0,0 +1,153 @@
1
+ """日志服务子进程的核心组件(与启动方式无关)。
2
+
3
+ 服务进程通过 ``python -m kitty_logger._server_main`` 由 :func:`kitty_logger.setup_logging`
4
+ 间接拉起,本模块只提供与"具体如何起进程"无关的纯逻辑:
5
+
6
+ - :func:`_bind_loopback`:在 loopback 地址上绑定一个 ``AF_INET`` 监听 socket。
7
+ - :class:`_LogRecordSocketReceiver`:基于 :class:`socketserver.ThreadingTCPServer`
8
+ 的接收器,复用外部已 ``bind`` 好的 socket。
9
+ - :func:`_build_handlers`:根据用户参数组装落地用的 handler 列表。
10
+ """
11
+
12
+ import logging
13
+ import pickle
14
+ import socket
15
+ import socketserver
16
+ import struct
17
+ import sys
18
+ from logging.handlers import TimedRotatingFileHandler
19
+ from typing import cast, final, override
20
+
21
+ from ._formatters import ColorFormatter, FileFormatter
22
+
23
+ # 这三个符号由同包内的 ``_server_main`` 模块导入复用;以下划线开头是因为它们
24
+ # 不属于库的公共 API(仅 kitty_logger 内部使用)。pyright 对下划线名默认按
25
+ # "模块内私有"处理,看不到跨模块引用,因此显式通过 ``__all__`` 声明为本模块
26
+ # 的对外导出,避免 reportUnusedFunction / reportUnusedClass 误报。
27
+ __all__ = ["_bind_loopback", "_LogRecordSocketReceiver", "_build_handlers"]
28
+
29
+ # 单条 LogRecord 序列化后字节数上限。任何正常 LogRecord 都远小于此值;
30
+ # 超过即视为协议错位或异常流量,直接断开连接,避免一次性分配巨大缓冲区
31
+ # 触发 OOM。
32
+ _MAX_RECORD_BYTES = 16 * 1024 * 1024
33
+
34
+
35
+ def _bind_loopback(
36
+ host: str, port: int, backlog: int = 128
37
+ ) -> tuple[socket.socket, str, int]:
38
+ """创建并绑定一个 ``AF_INET`` TCP 监听 socket,返回 ``(sock, bound_host, bound_port)``。
39
+
40
+ 把"AF_INET 的 ``getsockname`` 一定返回 ``tuple[str, int]``"这个静态
41
+ 事实集中收敛在这里,调用方不必再写 ``cast``。
42
+ """
43
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
44
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
45
+ sock.bind((host, port))
46
+ sock.listen(backlog)
47
+ bound_host, bound_port = cast(tuple[str, int], sock.getsockname())
48
+ return sock, bound_host, bound_port
49
+
50
+
51
+ def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
52
+ """从 ``conn`` 上读满 ``n`` 字节;对端已关闭则返回 ``None``。
53
+
54
+ 单次 ``recv`` 不保证读够请求长度(TCP 半包),且对端关闭时
55
+ ``recv`` 会立刻返回 ``b""``。任何"按长度循环 recv"的实现都必须显式
56
+ 处理 EOF,否则在客户端中途断连时会陷入死循环。
57
+ """
58
+ buf = bytearray()
59
+ while len(buf) < n:
60
+ try:
61
+ chunk = conn.recv(n - len(buf))
62
+ except (ConnectionError, OSError):
63
+ return None
64
+ if not chunk:
65
+ return None
66
+ buf.extend(chunk)
67
+ return bytes(buf)
68
+
69
+
70
+ @final
71
+ class _LogRecordStreamHandler(socketserver.StreamRequestHandler):
72
+ """处理一个客户端连接上的连续 LogRecord 流。
73
+
74
+ 协议沿用标准库 :class:`logging.handlers.SocketHandler`:
75
+ ``[uint32 大端长度][pickle 字节]``,可在同一连接上重复出现。
76
+ """
77
+
78
+ @override
79
+ def handle(self) -> None:
80
+ conn = cast(socket.socket, self.connection)
81
+ while True:
82
+ header = _recv_exact(conn, 4)
83
+ if header is None:
84
+ break
85
+ slen = cast(int, struct.unpack(">L", header)[0])
86
+ if slen == 0 or slen > _MAX_RECORD_BYTES:
87
+ break
88
+ body = _recv_exact(conn, slen)
89
+ if body is None:
90
+ break
91
+ try:
92
+ obj = cast(object, pickle.loads(body))
93
+ except Exception:
94
+ # 单条记录损坏不应连累整条连接;继续读下一条。
95
+ continue
96
+ if not isinstance(obj, dict):
97
+ continue
98
+ record = logging.makeLogRecord(cast(dict[str, object], obj))
99
+ logging.getLogger(record.name).handle(record)
100
+
101
+
102
+ @final
103
+ class _LogRecordSocketReceiver(socketserver.ThreadingTCPServer):
104
+ """基于 :class:`socketserver.ThreadingTCPServer` 的接收器。
105
+
106
+ 复用外部已 ``bind`` 好的监听 socket,本类不再自己 ``bind``/``listen``。
107
+ """
108
+
109
+ allow_reuse_address: bool = True
110
+ daemon_threads: bool = True
111
+
112
+ @override
113
+ def __init__(self, listening_sock: socket.socket) -> None:
114
+ addr = cast(tuple[str, int], listening_sock.getsockname()[:2])
115
+ # 走父类正常流程以建立 BaseServer 内部状态(shutdown 标志等),
116
+ # 但通过 ``bind_and_activate=False`` 跳过它自己的 bind/listen——
117
+ # 这个 socket 已经由调用方绑好。
118
+ super().__init__(addr, _LogRecordStreamHandler, bind_and_activate=False)
119
+ # 父类构造时仍会 ``self.socket = socket.socket(...)`` 占坑,
120
+ # 这里替换成真正复用的 socket,并把临时占位关闭掉。
121
+ try:
122
+ self.socket.close()
123
+ except OSError:
124
+ pass
125
+ self.socket = listening_sock
126
+ self.server_address = addr
127
+
128
+
129
+ def _build_handlers(
130
+ log_file: str | None,
131
+ stream: bool,
132
+ console_fmt: str,
133
+ file_fmt: str,
134
+ datefmt: str | None,
135
+ ) -> list[logging.Handler]:
136
+ """根据用户传入的参数组合出落地用的 handler 列表。"""
137
+ handlers: list[logging.Handler] = []
138
+ if log_file:
139
+ # 按天轮转,每天 0 点切一份,最多保留 365 天历史。
140
+ fh = TimedRotatingFileHandler(
141
+ log_file,
142
+ when="midnight",
143
+ interval=1,
144
+ backupCount=365,
145
+ encoding="utf-8",
146
+ )
147
+ fh.setFormatter(FileFormatter(fmt=file_fmt, datefmt=datefmt))
148
+ handlers.append(fh)
149
+ if stream:
150
+ sh = logging.StreamHandler(stream=sys.stderr)
151
+ sh.setFormatter(ColorFormatter(fmt=console_fmt, datefmt=datefmt))
152
+ handlers.append(sh)
153
+ return handlers
@@ -0,0 +1,129 @@
1
+ """kitty_logger 服务子进程入口。
2
+
3
+ 通过 ``python -m kitty_logger._server_main`` 启动,由
4
+ :func:`kitty_logger.setup_logging` 内部用 :class:`subprocess.Popen` 拉起。
5
+ 本模块不是给最终用户的接口。
6
+
7
+ 握手 / 生命周期协议
8
+ --------------------
9
+
10
+ 整套机制只用三条标准管道,不需要 ``pass_fds``,所以天然跨平台:
11
+
12
+ * ``stdin`` — 父→子,**parent-alive**:父进程保持 write 端打开但不写。
13
+ 父进程任何原因退出(``exit`` / ``kill -9`` / 段错误),内核会关掉它持有
14
+ 的写端 fd,子进程在本端 ``read()`` 立刻返回 ``b""`` → 触发优雅退出。
15
+ * ``stdout`` — 子→父,**启动握手**:子进程 ``bind+listen`` 成功后写出
16
+ ``"PORT <port>\\n"`` 然后 ``close``,握手即结束;之后不再使用 stdout
17
+ 以避免后续误用 ``print`` 污染父进程的读端。
18
+ * ``stderr`` — 子→父,**日志落地**:直接继承父进程的 stderr/tty。
19
+ ``--stream`` 控制是否给 root logger 挂上 :class:`logging.StreamHandler`。
20
+
21
+ 命令行参数对应 :func:`kitty_logger.setup_logging` 的同名参数。
22
+ """
23
+
24
+ import argparse
25
+ import logging
26
+ import sys
27
+ import threading
28
+
29
+ from ._server import _LogRecordSocketReceiver, _bind_loopback, _build_handlers
30
+
31
+
32
+ def _parse_args() -> argparse.Namespace:
33
+ ap = argparse.ArgumentParser(prog="kitty_logger._server_main", add_help=False)
34
+ ap.add_argument("--host", required=True)
35
+ ap.add_argument("--port", type=int, required=True)
36
+ ap.add_argument("--level", type=int, required=True)
37
+ ap.add_argument("--log-file", default=None)
38
+ ap.add_argument("--stream", action="store_true")
39
+ ap.add_argument("--console-fmt", required=True)
40
+ ap.add_argument("--file-fmt", required=True)
41
+ ap.add_argument("--datefmt", default=None)
42
+ return ap.parse_args()
43
+
44
+
45
+ def _emit_handshake(port: int) -> None:
46
+ """把 ``"PORT <port>\\n"`` 写到 stdout 并主动 close。
47
+
48
+ 主动 close 等于显式宣告"握手结束"——之后任何对 ``sys.stdout`` 的
49
+ 误用都不会再污染父进程的读端,父进程的 ``readline()`` 也只会看到
50
+ 我们刚写的这一行。
51
+ """
52
+ sys.stdout.write(f"PORT {port}\n")
53
+ sys.stdout.flush()
54
+ try:
55
+ sys.stdout.close()
56
+ except OSError:
57
+ pass
58
+
59
+
60
+ def _emit_handshake_error(msg: str) -> None:
61
+ """启动失败时通过 stdout 报错,让父端 ``readline()`` 看到非 ``PORT`` 行。"""
62
+ try:
63
+ sys.stdout.write(f"ERROR {msg}\n")
64
+ sys.stdout.flush()
65
+ sys.stdout.close()
66
+ except OSError:
67
+ pass
68
+
69
+
70
+ def main() -> None:
71
+ args = _parse_args()
72
+
73
+ # 先 bind+listen,这一步任何失败都通过 stdout 上报给父进程,让 setup_logging
74
+ # 能在主进程线程中以 ``RuntimeError`` 形式抛出,而不是父子双方各自卡住。
75
+ try:
76
+ sock, _bound_host, bound_port = _bind_loopback(args.host, args.port)
77
+ except OSError as e:
78
+ _emit_handshake_error(f"bind failed: {e}")
79
+ sys.exit(2)
80
+
81
+ _emit_handshake(bound_port)
82
+
83
+ # spawn 出来的全新解释器,root logger 默认无 handler;保险起见仍清一遍
84
+ # 以便重复初始化场景下保持幂等。
85
+ root = logging.getLogger()
86
+ for h in list(root.handlers):
87
+ root.removeHandler(h)
88
+ for h in _build_handlers(
89
+ args.log_file, args.stream, args.console_fmt, args.file_fmt, args.datefmt
90
+ ):
91
+ root.addHandler(h)
92
+ root.setLevel(args.level)
93
+
94
+ server = _LogRecordSocketReceiver(sock)
95
+
96
+ def _watch_parent_alive() -> None:
97
+ """父进程持有 stdin 的写端;任何原因(正常退出 / SIGKILL / 段错误)
98
+ 让父进程消失,内核都会关掉它的 fd,本端 ``read()`` 会立刻返回
99
+ ``b""``(EOF)。借此触发自我退出,避免变成孤儿进程。
100
+
101
+ 正常生命周期下父进程也通过 ``proc.stdin.close()`` 主动触发同一
102
+ 条 EOF 路径,把"父退出 / 父主动 shutdown" 收敛为同一种处理。
103
+ """
104
+ try:
105
+ while True:
106
+ data = sys.stdin.buffer.read(64)
107
+ if not data:
108
+ break
109
+ except (OSError, ValueError):
110
+ pass
111
+ finally:
112
+ server.shutdown()
113
+
114
+ threading.Thread(
115
+ target=_watch_parent_alive, name="kitty-logger-parent-watch", daemon=True
116
+ ).start()
117
+
118
+ try:
119
+ server.serve_forever()
120
+ finally:
121
+ try:
122
+ server.server_close()
123
+ except OSError:
124
+ pass
125
+ logging.shutdown()
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
@@ -6,13 +6,12 @@ import logging
6
6
  import multiprocessing as mp
7
7
  import os
8
8
  import socket
9
- from multiprocessing.process import BaseProcess
10
- from multiprocessing.synchronize import Event as MpEvent
11
- from typing import Literal, TypedDict, TypeGuard, cast
9
+ import subprocess
10
+ import sys
11
+ from typing import IO, Literal, TypedDict, TypeGuard, cast
12
12
 
13
13
  from ._env import ENV_HOST, ENV_LEVEL, ENV_PORT
14
14
  from ._formatters import DEFAULT_CONSOLE_FMT, DEFAULT_FILE_FMT
15
- from ._server import run_server
16
15
 
17
16
 
18
17
  class _NotRunningState(TypedDict):
@@ -20,17 +19,13 @@ class _NotRunningState(TypedDict):
20
19
  host: None
21
20
  port: None
22
21
  proc: None
23
- shutdown_event: None
24
- parent_alive_send: None
25
22
 
26
23
 
27
24
  class _RunningState(TypedDict):
28
25
  running: Literal[True]
29
26
  host: str
30
27
  port: int
31
- proc: BaseProcess
32
- shutdown_event: MpEvent
33
- parent_alive_send: socket.socket
28
+ proc: subprocess.Popen[bytes]
34
29
 
35
30
 
36
31
  type _ServerState = _NotRunningState | _RunningState
@@ -42,8 +37,6 @@ def _make_not_running_state() -> _NotRunningState:
42
37
  "host": None,
43
38
  "port": None,
44
39
  "proc": None,
45
- "shutdown_event": None,
46
- "parent_alive_send": None,
47
40
  }
48
41
 
49
42
 
@@ -103,20 +96,65 @@ def _check_loopback_only(host: str) -> None:
103
96
  )
104
97
 
105
98
 
106
- def _bind_ipv4_listener(
107
- host: str, port: int, backlog: int = 128
108
- ) -> tuple[socket.socket, str, int]:
109
- """创建并绑定一个 ``AF_INET`` TCP 监听 socket。
99
+ def _read_handshake(stdout: IO[bytes], proc: subprocess.Popen[bytes]) -> int:
100
+ """从服务子进程的 stdout 读一行握手信息,返回服务真实绑定的端口。
110
101
 
111
- 把"AF_INET ``getsockname`` 一定返回 ``tuple[str, int]``"这个静态
112
- 事实集中在这里收敛,调用方就不再需要写 ``cast``。
102
+ 协议在 :mod:`kitty_logger._server_main` 模块顶部说明。本函数把所有
103
+ ``"PORT <int>"`` 情况都收敛成 :class:`RuntimeError`:握手通道既然
104
+ 被设计成 stdout,就不允许除握手以外的任何字节出现。
113
105
  """
114
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
115
- sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
116
- sock.bind((host, port))
117
- sock.listen(backlog)
118
- bound_host, bound_port = cast(tuple[str, int], sock.getsockname())
119
- return sock, bound_host, bound_port
106
+ line = stdout.readline()
107
+ if not line:
108
+ # readline 返回空字节即对端 close 且没写任何东西——子进程在握手前
109
+ # 就退出了。等子进程结束以拿到 returncode 一并报告。
110
+ rc = proc.wait(timeout=2.0) if proc.poll() is None else proc.returncode
111
+ raise RuntimeError(
112
+ f"kitty_logger 服务子进程在握手前就退出了(exit code {rc})。"
113
+ )
114
+ text = line.decode("utf-8", errors="replace").rstrip("\r\n")
115
+ if text.startswith("PORT "):
116
+ try:
117
+ return int(text.split(" ", 1)[1])
118
+ except (ValueError, IndexError):
119
+ pass
120
+ if text.startswith("ERROR "):
121
+ raise RuntimeError(f"kitty_logger 服务子进程启动失败:{text[len('ERROR '):]}")
122
+ raise RuntimeError(f"kitty_logger 服务子进程握手协议异常,收到:{text!r}")
123
+
124
+
125
+ def _build_server_argv(
126
+ host: str,
127
+ port: int,
128
+ level: int,
129
+ log_file: str | None,
130
+ stream: bool,
131
+ console_fmt: str,
132
+ file_fmt: str,
133
+ datefmt: str | None,
134
+ ) -> list[str]:
135
+ """把 ``setup_logging`` 的参数翻译成服务子进程的命令行参数。"""
136
+ argv = [
137
+ sys.executable,
138
+ "-m",
139
+ "kitty_logger._server_main",
140
+ "--host",
141
+ host,
142
+ "--port",
143
+ str(port),
144
+ "--level",
145
+ str(level),
146
+ "--console-fmt",
147
+ console_fmt,
148
+ "--file-fmt",
149
+ file_fmt,
150
+ ]
151
+ if log_file is not None:
152
+ argv += ["--log-file", log_file]
153
+ if stream:
154
+ argv += ["--stream"]
155
+ if datefmt is not None:
156
+ argv += ["--datefmt", datefmt]
157
+ return argv
120
158
 
121
159
 
122
160
  def setup_logging(
@@ -131,7 +169,7 @@ def setup_logging(
131
169
  ) -> tuple[str, int]:
132
170
  """在主进程里启动跨进程日志服务,返回服务真实绑定到的 ``(host, port)``。
133
171
 
134
- 在一个独立的 ``spawn`` 子进程里运行 TCP 接收器,把所有
172
+ 在一个独立的 :class:`subprocess.Popen` 子进程里运行 TCP 接收器,把所有
135
173
  :class:`logging.LogRecord` 写到 ``log_file`` 和/或 ``stderr``。同时把
136
174
  ``KITTY_LOGGER_HOST`` / ``KITTY_LOGGER_PORT`` / ``KITTY_LOGGER_LEVEL``
137
175
  写入 :data:`os.environ`,使得后续 ``spawn`` 出来的子进程可以无配置地
@@ -141,16 +179,6 @@ def setup_logging(
141
179
  无颜色的 :class:`FileFormatter`,两者均使用毫秒级时间戳。
142
180
 
143
181
  幂等:重复调用直接返回已记录的 ``(host, port)``,不会重复启动子进程。
144
- 在 ``multiprocessing`` 启动的子进程里调用本函数时,会自动判断当前
145
- 不是原始主进程并直接返回从父进程继承的 ``(host, port)``,本身不再
146
- 启动新的日志服务——这意味着用户可以把 ``setup_logging()`` 直接放在
147
- 模块顶层而不必用 ``if __name__ == "__main__":`` 包裹。
148
-
149
- 本函数同时把主进程内 ``logging.getLogger("kitty")`` 这一个 logger
150
- 初始化好——挂上指向日志服务的 :class:`SocketHandler`、设置 level、
151
- 并把 ``propagate`` 关掉。所有通过 :func:`kitty_logger.getLogger`
152
- 取到的子 logger 都挂在 ``kitty`` 这棵子树下,借标准库的祖先链冒泡到
153
- ``kitty`` 的这个 handler 上,无需自己额外配置。
154
182
 
155
183
  :param log_file: 日志文件路径;``None`` 表示不落盘。
156
184
  :param level: 服务端 root logger 的级别,同时也是主进程 ``kitty``
@@ -177,18 +205,20 @@ def setup_logging(
177
205
  请使用 ``multiprocessing.get_context("spawn")``,或在顶层用
178
206
  ``if __name__ == "__main__":`` 守卫配合默认 ``spawn`` 行为。
179
207
  """
180
- # ``multiprocessing`` 启动出来的子进程里再次执行 setup_logging(典型
181
- # 场景:spawn bootstrap 通过 ``_fixup_main_from_path`` 重新 import 主
182
- # 脚本,从而触发主脚本顶层那行 ``kitty_logger.setup_logging(...)``)
183
- # 一律走 no-op:日志服务只该在原始主进程里跑一份;子进程通过继承的
184
- # ENV 找服务地址,``getLogger`` 第一次被调用时再按需挂 SocketHandler。
208
+ # ``multiprocessing.Process`` 启动出来的 spawn 子进程在 bootstrap 阶段会
209
+ # 通过 ``_fixup_main_from_path`` 重新执行用户主脚本顶层;如果用户把
210
+ # ``setup_logging(...)`` 写在了模块顶层,本函数会在每个 worker 子进程里
211
+ # 也被调到。此时不应再起一份服务,而是直接返回从父进程继承的 ENV——
212
+ # worker 的 getLogger 自动连上原始服务即可。
213
+ # 注意:本库自己 ``Popen`` 拉起的服务子进程不会触发这条路径,因为它
214
+ # 跑的是 ``-m kitty_logger._server_main``,并不会回头执行用户主脚本。
185
215
  if mp.parent_process() is not None:
186
216
  host_env = os.environ.get(ENV_HOST)
187
217
  port_env = os.environ.get(ENV_PORT)
188
218
  if not host_env or not port_env:
189
219
  raise RuntimeError(
190
- "kitty_logger 在子进程里被调用 setup_logging,但没有从父进程"
191
- "继承到 KITTY_LOGGER_HOST / KITTY_LOGGER_PORT 环境变量。"
220
+ "kitty_logger multiprocessing 子进程里被调用 setup_logging"
221
+ "但没有从父进程继承到 KITTY_LOGGER_HOST / KITTY_LOGGER_PORT 环境变量。"
192
222
  "请确保父进程已经先调用 setup_logging() 后再 spawn 本进程。"
193
223
  )
194
224
  return host_env, int(port_env)
@@ -199,57 +229,79 @@ def setup_logging(
199
229
 
200
230
  _check_loopback_only(host)
201
231
 
202
- # 在父进程里直接 bind,使 ``port=0`` 也能同步拿到真实端口;然后把
203
- # 已绑定的 socket 交给服务子进程使用。
204
- listening_sock, bound_host, bound_port = _bind_ipv4_listener(host, port)
232
+ argv = _build_server_argv(
233
+ host=host,
234
+ port=port,
235
+ level=level,
236
+ log_file=log_file,
237
+ stream=stream,
238
+ console_fmt=console_fmt,
239
+ file_fmt=file_fmt,
240
+ datefmt=datefmt,
241
+ )
242
+ # 把本包所在目录拼进子进程的 ``PYTHONPATH``,保证子进程的解释器能
243
+ # ``import`` 到 ``kitty_logger._server_main``。已经 ``pip install`` 装到
244
+ # site-packages 的场景下这步是冗余但无害的;开发场景(仅靠
245
+ # ``pytest --pythonpath=src`` 把 ``src/`` 临时拼到 pytest 自己的 sys.path)
246
+ # 则必须显式传递,否则全新启动的子解释器看不到包路径。
247
+ pkg_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
248
+ child_env = os.environ.copy()
249
+ existing = child_env.get("PYTHONPATH", "")
250
+ child_env["PYTHONPATH"] = pkg_parent + (os.pathsep + existing if existing else "")
251
+
252
+ # 三条管道的角色见 :mod:`kitty_logger._server_main` 模块文档:
253
+ # stdin = 父→子,仅用于探活(父挂掉子读到 EOF);
254
+ # stdout = 子→父,仅用于一次握手;
255
+ # stderr = 子→父,直通父的 stderr/tty,由服务端 StreamHandler 落地日志。
256
+ proc: subprocess.Popen[bytes] = subprocess.Popen(
257
+ argv,
258
+ stdin=subprocess.PIPE,
259
+ stdout=subprocess.PIPE,
260
+ stderr=None,
261
+ env=child_env,
262
+ )
263
+ # ``readline`` 用 buffered IO;为保证我们看到子进程的 ``flush+close``
264
+ # 之后立刻拿到那一行,要走二进制接口手动解码。
265
+ stdout = cast(IO[bytes], proc.stdout)
266
+ try:
267
+ bound_port = _read_handshake(stdout, proc)
268
+ except BaseException:
269
+ # 握手失败时主动结束子进程,避免留下孤儿。
270
+ try:
271
+ if proc.stdin is not None:
272
+ proc.stdin.close()
273
+ except OSError:
274
+ pass
275
+ try:
276
+ proc.kill()
277
+ except OSError:
278
+ pass
279
+ try:
280
+ proc.wait(timeout=2.0)
281
+ except subprocess.TimeoutExpired:
282
+ pass
283
+ raise
284
+ # 握手通道在父侧也立刻关掉,子侧已 close,保留只是浪费 fd。
285
+ try:
286
+ stdout.close()
287
+ except OSError:
288
+ pass
205
289
 
206
- # 父进程存活监测:父进程持有 send 端,子进程持有 recv 端。父进程哪怕
207
- # 被 ``kill -9`` 也会让内核 close 它的 fd,子进程在另一端会读到 EOF。
208
- parent_alive_send, parent_alive_recv = socket.socketpair()
290
+ bound_host = host
209
291
 
210
- # ENV 必须在 ``proc.start()`` 之前写入:spawn 出的服务子进程虽然通过
211
- # kwargs 拿到了所有运行参数,但它在 bootstrap 阶段会用
212
- # ``_fixup_main_from_path`` 重新 import 主脚本,主脚本顶层若间接触发
213
- # ``kitty_logger.getLogger(...)``(例如某个被顶层 import 的库在模块级
214
- # 调用了 getLogger),此时子进程必须已经能从 ENV 读到服务地址,否则
215
- # 子进程会在 import 阶段抛 RuntimeError 而异常退出。
292
+ # 这一段必须在 spawn 任何后代之前完成:后代要靠这些 ENV 找服务地址。
293
+ # ``Popen`` 不会像 ``multiprocessing.Process`` 那样去 re-import 主脚本,
294
+ # 因此本函数本身可以放在模块顶层,无需 ``if __name__ == "__main__":``
295
+ # 守卫——服务子进程不会回头执行用户主脚本的任何顶层代码。
216
296
  os.environ[ENV_HOST] = bound_host
217
297
  os.environ[ENV_PORT] = str(bound_port)
218
298
  os.environ[ENV_LEVEL] = str(level)
219
299
 
220
- ctx = mp.get_context("spawn")
221
- shutdown_event = ctx.Event()
222
- proc = ctx.Process(
223
- target=run_server,
224
- kwargs=dict(
225
- listening_sock=listening_sock,
226
- shutdown_event=shutdown_event,
227
- parent_alive_recv=parent_alive_recv,
228
- level=level,
229
- log_file=log_file,
230
- stream=stream,
231
- console_fmt=console_fmt,
232
- file_fmt=file_fmt,
233
- datefmt=datefmt,
234
- ),
235
- name="kitty-logger-server",
236
- daemon=False,
237
- )
238
- proc.start()
239
-
240
- # 父进程不再需要这两个 socket 副本:
241
- # - listening_sock 已经完全交给子进程;
242
- # - parent_alive_recv 必须只由子进程持有,否则 EOF 永远不会到来。
243
- listening_sock.close()
244
- parent_alive_recv.close()
245
-
246
300
  new_state: _RunningState = {
247
301
  "running": True,
248
302
  "host": bound_host,
249
303
  "port": bound_port,
250
304
  "proc": proc,
251
- "shutdown_event": shutdown_event,
252
- "parent_alive_send": parent_alive_send,
253
305
  }
254
306
  _state = new_state
255
307
 
@@ -306,36 +358,42 @@ def shutdown_logging(timeout: float = 5.0) -> None:
306
358
  if not _is_running(state=_state):
307
359
  return
308
360
 
309
- event: MpEvent = _state["shutdown_event"]
310
- proc: BaseProcess = _state["proc"]
311
- parent_alive_send: socket.socket = _state["parent_alive_send"]
361
+ proc: subprocess.Popen[bytes] = _state["proc"]
312
362
  host: str = _state["host"]
313
363
  port: int = _state["port"]
314
364
 
315
365
  _state = _make_not_running_state()
316
366
 
367
+ # 关掉 stdin 的写端 = 子进程那侧 ``read()`` 立刻得到 EOF,
368
+ # 触发 ``server.shutdown()`` 走优雅退出路径。这是首选信号。
369
+ if proc.stdin is not None:
370
+ try:
371
+ proc.stdin.close()
372
+ except OSError:
373
+ pass
374
+
317
375
  try:
318
- event.set()
319
- except Exception:
320
- pass
321
- # 主动关闭父端 socket,作为 mp.Event 的兜底关闭信号——子进程的
322
- # ``_watch_parent_alive`` 线程会立刻读到 EOF。
323
- try:
324
- parent_alive_send.close()
325
- except OSError:
326
- pass
376
+ proc.wait(timeout=timeout)
377
+ except subprocess.TimeoutExpired:
378
+ try:
379
+ proc.terminate()
380
+ except OSError:
381
+ pass
382
+ try:
383
+ proc.wait(timeout=1.0)
384
+ except subprocess.TimeoutExpired:
385
+ pass
327
386
 
328
- proc.join(timeout)
329
- if proc.is_alive():
330
- proc.terminate()
331
- proc.join(1.0)
332
- if proc.is_alive():
387
+ if proc.poll() is None:
333
388
  # ``terminate`` 没杀掉(被 IO 阻塞在不可中断状态):兜底 kill。
334
389
  try:
335
390
  proc.kill()
336
- except Exception:
391
+ except OSError:
392
+ pass
393
+ try:
394
+ proc.wait(timeout=1.0)
395
+ except subprocess.TimeoutExpired:
337
396
  pass
338
- proc.join(1.0)
339
397
 
340
398
  # 服务进程已经死了,本进程里指向它的 SocketHandler 必须全部取下,
341
399
  # 否则后续 logging 调用会向一个失效端口发送,触发静默丢日志。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kitty_logger
3
- Version: 0.2.0.dev2
3
+ Version: 0.2.0.dev3
4
4
  Summary: Cross-process logging via a dedicated log server process and SocketHandler.
5
5
  Author: Kitty
6
6
  License: MIT
@@ -55,13 +55,15 @@ if __name__ == "__main__":
55
55
  ## API
56
56
 
57
57
  - `setup_logging(log_file=None, level=logging.INFO, host="127.0.0.1", port=0, stream=True, console_fmt=..., file_fmt=..., datefmt=None) -> (host, port)`
58
- 启动日志服务子进程(始终使用 `spawn`)。幂等。已通过 `atexit` 注册清理。
59
- `port=0` 让操作系统挑选空闲端口;返回真实绑定到的 `(host, port)`。
58
+ 通过 `subprocess.Popen` 启动一个独立的日志服务子进程(不依赖
59
+ `multiprocessing`,因此**不会回头执行用户主脚本的任何顶层代码**)。
60
+ 幂等。已通过 `atexit` 注册清理。`port=0` 让操作系统挑选空闲端口;返回
61
+ 真实绑定到的 `(host, port)`。
60
62
  **`host` 必须是 loopback**——绑定非 loopback 地址会直接 `ValueError`。
61
63
  同时把主进程内 `logging.getLogger("kitty")` 这一个 logger 初始化好——挂上
62
64
  `SocketHandler`、设置 level、关闭 `propagate`。**不动 root logger**。
63
- `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op,仅返回
64
- 从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
65
+ 在用户用 `multiprocessing` 启动的子进程里再次调用本函数会自动 no-op
66
+ 仅返回从父进程继承的 `(host, port)`,因此可以直接放在模块顶层而不必用
65
67
  `if __name__ == "__main__":` 包裹。
66
68
  - `getLogger(name=None) -> logging.Logger`
67
69
  返回挂在 `kitty` 命名空间下的 logger:`getLogger("foo")` 实际拿到的是
@@ -6,6 +6,7 @@ src/kitty_logger/_client.py
6
6
  src/kitty_logger/_env.py
7
7
  src/kitty_logger/_formatters.py
8
8
  src/kitty_logger/_server.py
9
+ src/kitty_logger/_server_main.py
9
10
  src/kitty_logger/_setup.py
10
11
  src/kitty_logger.egg-info/PKG-INFO
11
12
  src/kitty_logger.egg-info/SOURCES.txt
@@ -310,50 +310,6 @@ def test_child_logger_propagates_to_kitty(tmp_path: Path):
310
310
  _wait_for_lines(log_path, lambda t: "from-foo-bar" in t and "kitty.foo.bar" in t)
311
311
 
312
312
 
313
- def test_server_resilient_to_module_level_getlogger_in_user_script(tmp_path: Path):
314
- """回归:用户主脚本顶层 import 的库在模块级调用 kitty_logger.getLogger(...)
315
- 时,服务子进程在 ``spawn`` bootstrap 阶段也会触发该模块级调用,从而把
316
- 服务自身的 ``kitty`` logger 挂上一个指向本服务监听端口的 SocketHandler,
317
- 使得接收到的 record 顺着祖先链被发回服务自身、永远不落地。
318
- ``run_server`` 必须在开始 serve 之前把 ``kitty`` logger 还原成无 handler、
319
- propagate=True 的状态。
320
- """
321
- import subprocess
322
- import sys as _sys
323
-
324
- fake_lib = tmp_path / "fake_lib.py"
325
- fake_lib.write_text(
326
- "import kitty_logger\n" "log = kitty_logger.getLogger(__name__)\n",
327
- encoding="utf-8",
328
- )
329
-
330
- log_file = tmp_path / "app.log"
331
- main_py = tmp_path / "main.py"
332
- main_py.write_text(
333
- "import sys, logging, time\n"
334
- f"sys.path.insert(0, {str(tmp_path)!r})\n"
335
- "from kitty_logger import setup_logging, getLogger\n"
336
- "if __name__ == '__main__':\n"
337
- f" setup_logging(log_file={str(log_file)!r}, stream=False, level=logging.DEBUG)\n"
338
- "import fake_lib # noqa: E402,F401 -- 模块级 getLogger 触发回归路径\n"
339
- "if __name__ == '__main__':\n"
340
- " getLogger('user').info('real-user-log')\n"
341
- " time.sleep(1)\n",
342
- encoding="utf-8",
343
- )
344
-
345
- result = subprocess.run(
346
- [_sys.executable, str(main_py)],
347
- capture_output=True,
348
- text=True,
349
- timeout=20,
350
- )
351
- assert result.returncode == 0, f"stderr={result.stderr}"
352
-
353
- text = _wait_for_lines(log_file, lambda t: "real-user-log" in t)
354
- assert "kitty.user" in text
355
-
356
-
357
313
  def _spawn_worker_calling_setup(idx: int, log_file: str) -> None:
358
314
  """子进程里也调一次 setup_logging:应该是 no-op,仅返回从 ENV 继承的端点。"""
359
315
  h, p = kitty_logger.setup_logging(log_file=log_file, stream=False)
@@ -1,228 +0,0 @@
1
- """日志服务子进程实现。
2
-
3
- 工作模型:
4
-
5
- 1. 父进程在 :func:`kitty_logger.setup_logging` 中先 ``bind`` 一个 TCP
6
- 监听 socket,再连同其他参数一起传给一个 ``spawn`` 启动的子进程。
7
- 2. 子进程在 :func:`run_server` 里基于这个 socket 跑 ``ThreadingTCPServer``,
8
- 反序列化客户端通过 :class:`logging.handlers.SocketHandler` 发来的
9
- ``LogRecord`` 字节流,再用本进程里配置的 handler(FileHandler /
10
- StreamHandler)落地。
11
- """
12
-
13
- import logging
14
- import pickle
15
- import socket
16
- import socketserver
17
- import struct
18
- import sys
19
- import threading
20
- from logging.handlers import TimedRotatingFileHandler
21
- from multiprocessing.synchronize import Event as MpEvent
22
- from typing import cast, final, override
23
-
24
- from ._formatters import ColorFormatter, FileFormatter
25
-
26
- # 单条 LogRecord 序列化后字节数上限。任何正常 LogRecord 都远小于此值;
27
- # 超过即视为协议错位或异常流量,直接断开连接,避免一次性分配巨大缓冲区
28
- # 触发 OOM。
29
- _MAX_RECORD_BYTES = 16 * 1024 * 1024
30
-
31
-
32
- def _recv_exact(conn: socket.socket, n: int) -> bytes | None:
33
- """从 ``conn`` 上读满 ``n`` 字节;对端已关闭则返回 ``None``。
34
-
35
- 单次 ``recv`` 不保证读够请求长度(TCP 半包),且对端关闭时
36
- ``recv`` 会立刻返回 ``b""``。任何"按长度循环 recv"的实现都必须显式
37
- 处理 EOF,否则在客户端中途断连时会陷入死循环。
38
- """
39
- buf = bytearray()
40
- while len(buf) < n:
41
- try:
42
- chunk = conn.recv(n - len(buf))
43
- except (ConnectionError, OSError):
44
- return None
45
- if not chunk:
46
- return None
47
- buf.extend(chunk)
48
- return bytes(buf)
49
-
50
-
51
- @final
52
- class _LogRecordStreamHandler(socketserver.StreamRequestHandler):
53
- """处理一个客户端连接上的连续 LogRecord 流。
54
-
55
- 协议沿用标准库 :class:`logging.handlers.SocketHandler`:
56
- ``[uint32 大端长度][pickle 字节]``,可在同一连接上重复出现。
57
- """
58
-
59
- @override
60
- def handle(self) -> None:
61
- conn = cast(socket.socket, self.connection)
62
- while True:
63
- header = _recv_exact(conn, 4)
64
- if header is None:
65
- break
66
- slen = cast(int, struct.unpack(">L", header)[0])
67
- if slen == 0 or slen > _MAX_RECORD_BYTES:
68
- break
69
- body = _recv_exact(conn, slen)
70
- if body is None:
71
- break
72
- try:
73
- obj = cast(object, pickle.loads(body))
74
- except Exception:
75
- # 单条记录损坏不应连累整条连接;继续读下一条。
76
- continue
77
- if not isinstance(obj, dict):
78
- continue
79
- record = logging.makeLogRecord(cast(dict[str, object], obj))
80
- logging.getLogger(record.name).handle(record)
81
-
82
-
83
- @final
84
- class _LogRecordSocketReceiver(socketserver.ThreadingTCPServer):
85
- """基于 :class:`socketserver.ThreadingTCPServer` 的接收器。
86
-
87
- 复用父进程已 ``bind`` 好的监听 socket,本类不再自己 ``bind``/``listen``。
88
- """
89
-
90
- allow_reuse_address: bool = True
91
- daemon_threads: bool = True
92
-
93
- @override
94
- def __init__(self, listening_sock: socket.socket) -> None:
95
- addr = cast(tuple[str, int], listening_sock.getsockname()[:2])
96
- # 走父类正常流程以建立 BaseServer 内部状态(shutdown 标志等),
97
- # 但通过 ``bind_and_activate=False`` 跳过它自己的 bind/listen——
98
- # 这个 socket 已经由父进程绑好。
99
- super().__init__(addr, _LogRecordStreamHandler, bind_and_activate=False)
100
- # 父类构造时仍会 ``self.socket = socket.socket(...)`` 占坑,
101
- # 这里替换成真正复用的 socket,并把临时占位关闭掉。
102
- try:
103
- self.socket.close()
104
- except OSError:
105
- pass
106
- self.socket = listening_sock
107
- self.server_address = addr
108
-
109
-
110
- def _build_handlers(
111
- log_file: str | None,
112
- stream: bool,
113
- console_fmt: str,
114
- file_fmt: str,
115
- datefmt: str | None,
116
- ) -> list[logging.Handler]:
117
- """根据用户传入的参数组合出落地用的 handler 列表。"""
118
- handlers: list[logging.Handler] = []
119
- if log_file:
120
- # 按天轮转,每天 0 点切一份,最多保留 365 天历史。
121
- fh = TimedRotatingFileHandler(
122
- log_file,
123
- when="midnight",
124
- interval=1,
125
- backupCount=365,
126
- encoding="utf-8",
127
- )
128
- fh.setFormatter(FileFormatter(fmt=file_fmt, datefmt=datefmt))
129
- handlers.append(fh)
130
- if stream:
131
- sh = logging.StreamHandler(stream=sys.stderr)
132
- sh.setFormatter(ColorFormatter(fmt=console_fmt, datefmt=datefmt))
133
- handlers.append(sh)
134
- return handlers
135
-
136
-
137
- def run_server(
138
- listening_sock: socket.socket,
139
- shutdown_event: MpEvent,
140
- parent_alive_recv: socket.socket,
141
- level: int,
142
- log_file: str | None,
143
- stream: bool,
144
- console_fmt: str,
145
- file_fmt: str,
146
- datefmt: str | None,
147
- ) -> None:
148
- """日志服务子进程入口函数。
149
-
150
- 通过 :mod:`multiprocessing` 在 ``spawn`` 出来的全新解释器里运行;
151
- 所有依赖参数显式从 ``kwargs`` 进入,不依赖父进程的全局状态。
152
-
153
- :param listening_sock: 已经由父进程 ``bind`` + ``listen`` 好的 socket。
154
- :param shutdown_event: 父进程通知子进程正常退出用的事件。
155
- :param parent_alive_recv: ``socketpair`` 的接收端,用来侦测父进程死亡。
156
- :param level: 服务端 root logger 级别。
157
- :param log_file: 日志文件路径;``None`` 表示不落盘。
158
- :param stream: 是否同时输出到 ``stderr``。
159
- :param console_fmt: 控制台 handler 的 format 字符串。
160
- :param file_fmt: 文件 handler 的 format 字符串。
161
- :param datefmt: 时间戳的 ``strftime`` 格式;``None`` 走默认值。
162
- """
163
- # 子进程是 ``spawn`` 出来的全新解释器,root logger 默认无 handler;
164
- # 但仍保险地清掉一遍以便重复初始化场景下的幂等性。
165
- root = logging.getLogger()
166
- for h in list(root.handlers):
167
- root.removeHandler(h)
168
- for h in _build_handlers(log_file, stream, console_fmt, file_fmt, datefmt):
169
- root.addHandler(h)
170
- root.setLevel(level)
171
-
172
- # 防御 ``spawn`` bootstrap:服务子进程在 ``_fixup_main_from_path`` 重新
173
- # import 用户主脚本时,主脚本顶层 import 的第三方库可能在模块级调用
174
- # ``kitty_logger.getLogger(...)``。由于父进程已经把 KITTY_LOGGER_HOST/
175
- # PORT/LEVEL 写进 ENV,本服务子进程里的 ``_ensure_kitty_handler()`` 会
176
- # 顺利地往 ``kitty`` logger 上挂一个指向本服务自己监听端口的
177
- # :class:`SocketHandler`,并把 ``propagate`` 关掉。结果是:本服务接收
178
- # 到记录后调 ``logging.getLogger(record.name).handle(record)``,记录
179
- # 沿祖先链冒泡到 ``kitty`` 时被这个 handler 捞走,原样发回给本服务
180
- # 自身(形成网络回环),同时因为 ``propagate=False`` 而不会再到达
181
- # root 上配置好的落地 handler,外部表现为日志凭空消失。
182
- # 因此在开始 serve 之前,强制把 ``kitty`` logger 还原成"无 handler、
183
- # propagate=True、level=NOTSET",让记录稳稳地冒泡到 root。
184
- kitty = logging.getLogger("kitty")
185
- for h in list(kitty.handlers):
186
- kitty.removeHandler(h)
187
- try:
188
- h.close()
189
- except OSError:
190
- pass
191
- kitty.propagate = True
192
- kitty.setLevel(logging.NOTSET)
193
-
194
- server = _LogRecordSocketReceiver(listening_sock)
195
-
196
- def _wait_shutdown() -> None:
197
- _ = shutdown_event.wait()
198
- server.shutdown()
199
-
200
- def _watch_parent_alive() -> None:
201
- # 父进程持有 socketpair 的发送端;任意原因(正常退出 / SIGKILL /
202
- # 段错误)使父进程消失,内核都会关闭它的 fd,本端 ``recv`` 会立刻
203
- # 返回空字节(EOF)。借此触发自我退出,避免变成孤儿进程。
204
- try:
205
- while True:
206
- data = parent_alive_recv.recv(64)
207
- if not data:
208
- break
209
- except OSError:
210
- pass
211
- finally:
212
- try:
213
- parent_alive_recv.close()
214
- except OSError:
215
- pass
216
- shutdown_event.set()
217
-
218
- threading.Thread(target=_wait_shutdown, daemon=True).start()
219
- threading.Thread(target=_watch_parent_alive, daemon=True).start()
220
-
221
- try:
222
- server.serve_forever()
223
- finally:
224
- try:
225
- server.server_close()
226
- except OSError:
227
- pass
228
- logging.shutdown()