tasklite-engine 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,204 @@
1
+ """框架默认的**管线脚手架**通用工具。
2
+
3
+ 与非领域相关,任何“驱动 pipeline → 跑 → 停机 / 清除 DLQ / 确定性 job_id /
4
+ 进度输出 / 文件系统瞬态注册”的消费方都可以复用。各种领域编排管线均可依赖本模块。
5
+
6
+ 涵盖:
7
+ - ``run_pipeline`` / ``clear_dlq`` / ``job_ref`` / ``progress_hook`` /
8
+ ``slice_list``:驱动与运维公共部分;
9
+ - ``content_fingerprint``(确定性版本盐指纹)与 ``sanitize_job_component``:
10
+ 内容任务 job_id 的通用派生原语(对应“job_id 必须确定性、不含 ::、
11
+ 无随机后缀”红线);
12
+ - ``register_file_transients``:把常见文件系统环境错误注册为本 pipeline
13
+ 的瞬态异常(磁盘满/只读/IO 抖动可重试,文件消失等确定性错误不注册)。
14
+ """
15
+
16
+ import hashlib
17
+ import logging
18
+ import re
19
+ from typing import Iterable, List, Optional, Sequence, Union
20
+
21
+ from .pipeline import TaskLite
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def run_pipeline(pipeline: TaskLite) -> None:
27
+ """统一 run + 停机包装:Ctrl+C 触发优雅 DRAINING 后正常返回。
28
+
29
+ 库函数不做进程级副作用(不 sys.exit、不 print)——退出码与用户提示
30
+ 属于 CLI 层职责。KeyboardInterrupt 经 stop 转为 DRAINING 优雅停机,
31
+ run 自然返回后本函数也返回;stop 自身的异常记录到 logger 而非
32
+ 静默吞掉。
33
+ """
34
+ try:
35
+ pipeline.run()
36
+ except KeyboardInterrupt:
37
+ logger.info("收到 Ctrl+C,请求优雅停机(DRAINING)……")
38
+ finally:
39
+ try:
40
+ pipeline.stop()
41
+ except Exception:
42
+ # stop 失败不掩盖原始异常,但必须留下痕迹而非静默
43
+ logger.exception("run_pipeline 收尾 stop 失败")
44
+
45
+
46
+ def clear_dlq(
47
+ pipeline: TaskLite,
48
+ *,
49
+ task_types: Optional[Sequence[str]] = None,
50
+ keep_fatal: bool = True,
51
+ ) -> int:
52
+ """官方 ``pipeline.clear_dlq`` 的便捷包装(清 DLQ 供重跑)。
53
+
54
+ - 前置任务类型(discovery / orchestrator)中断残留 → 清后可重跑;
55
+ - ``keep_fatal=False`` 连确定性失败一并清(如用户修复 Cookie/LLM 欠费后)。
56
+
57
+ 严格透传:仅 ``None`` 表示全部;空列表如实传递(按 0 个类型过滤 =
58
+ 删除 0 条),不做 truthy 改写——否则 ``task_types=[]`` 会被静默
59
+ 当作 ``None``(清除全部 DLQ 条目)。
60
+ """
61
+ return pipeline.clear_dlq(
62
+ task_types=list(task_types) if task_types is not None else None,
63
+ keep_fatal=keep_fatal)
64
+
65
+
66
+ def job_ref(meta) -> str:
67
+ """从 result_meta 提取人类可读引用(#作品id / 画师名 / 任意 id 字段)。"""
68
+ if isinstance(meta, dict):
69
+ if "post_id" in meta:
70
+ return f"#{meta['post_id']}"
71
+ if "artist" in meta:
72
+ return meta["artist"]
73
+ if "id" in meta:
74
+ return str(meta["id"])
75
+ return ""
76
+
77
+
78
+ def progress_hook(uid: str, meta, success: bool, going_to_retry: bool) -> None:
79
+ """父进程侧每 job 终结回调:一行进度输出(钩子)。"""
80
+ task_type = uid.split("::", 1)[0]
81
+ ref = job_ref(meta)
82
+ label = f"{task_type} {ref}" if ref else task_type
83
+ if going_to_retry:
84
+ print(f" ⟳ {label} 失败,退避重试", flush=True)
85
+ elif success:
86
+ print(f" ✓ {label}", flush=True)
87
+ else:
88
+ print(f" ✗ {label} → DLQ", flush=True)
89
+
90
+
91
+ def slice_list(items: List, start: Optional[int], count: Optional[int], limit: Optional[int]) -> List:
92
+ """分批切片:先 limit,再 [start:start+count]。"""
93
+ if limit is not None:
94
+ items = items[:limit]
95
+ if start is not None or count is not None:
96
+ s = start if start is not None else 0
97
+ c = count if count is not None else len(items)
98
+ items = items[s:s + c]
99
+ return items
100
+
101
+
102
+
103
+ # 确定性 job_id 原语
104
+
105
+
106
+ def content_fingerprint(parts: Iterable[Union[str, int, float]], *, version: str = "") -> str:
107
+ """内容指纹 → 确定性 job_id 片段(sha1,截 16 位,可读)。
108
+
109
+ 任何输入变化(含 bump ``version`` 盐)→ 指纹变化 → wall 不命中 → 重跑。
110
+ 与“job_id 确定性、禁止随机后缀”红线一致:给定输入恒同指纹。
111
+ """
112
+ h = hashlib.sha1()
113
+ if version:
114
+ h.update(str(version).encode("utf-8", errors="replace"))
115
+ h.update(b"\x00")
116
+ for p in parts:
117
+ h.update(str(p).encode("utf-8", errors="replace"))
118
+ h.update(b"\x00")
119
+ return h.hexdigest()[:16]
120
+
121
+
122
+ # job_id 成分转义禁止集:路径分隔符(文件名安全)、冒号(uid 的 ::
123
+ # 分隔符冲突)、转义符本身(先转义 % 才能保证编码序列无歧义)。
124
+ _JOB_COMPONENT_FORBIDDEN = "/\\:%"
125
+
126
+
127
+ def _escape_job_component(text: str) -> str:
128
+ """单射转义:禁止字符/不可打印字符按 UTF-8 字节 → ``%XX``(固定 2 位)。
129
+
130
+ 转义符 ``%`` 本身属于禁止集、总是先被转义为 ``%25``,因此输出中的
131
+ ``%`` 只可能开启转义序列且必跟恰好 2 位 hex,永不与用户输入的 ``%``
132
+ 混淆——这是映射可唯一逆解析(单射)的关键。
133
+ """
134
+ parts = []
135
+ for ch in text:
136
+ if ch.isprintable() and ch not in _JOB_COMPONENT_FORBIDDEN:
137
+ parts.append(ch)
138
+ else:
139
+ parts.append("".join(f"%{b:02X}" for b in ch.encode("utf-8")))
140
+ return "".join(parts)
141
+
142
+
143
+ def sanitize_job_component(value: str, *, max_len: int = 120) -> str:
144
+ r"""把任意字符串**转义**净化为可作 job_id 成分的串(单射,不删字符)。
145
+
146
+ 转义规则(与 ``discovery.sanitize_content_id`` 同款百分号转义哲学):
147
+ 可打印且非 ``/ \ : %`` 的字符原样保留;其余字符——路径分隔符 ``/``
148
+ 与 ``\``、冒号 ``:``(uid 的 ``::`` 分隔符冲突)、不可打印字符、转义符
149
+ ``%`` 本身——按 UTF-8 字节逐个转义为 ``%XX``(固定 2 位 hex)。输出
150
+ 因此不含 ``::``、路径分隔符与控制字符,可直接作文件名安全成分;
151
+ 全程无随机化,同一输入恒同输出。
152
+
153
+ 单射性保证(不同输入必产生不同输出):用户输入的 ``%`` 总被转义为
154
+ ``%25``,故输出中的 ``%`` 只可能开启一个转义序列且必跟恰好 2 位 hex;
155
+ 据此输出可被唯一切分为「字面字符 | %XX 字节」标记流,字节流再经
156
+ UTF-8(前缀无关编码)唯一还原为输入字符序列,映射在数学上单射。
157
+
158
+ 为什么删除式净化不可用:直接删掉禁止字符是多对一映射——
159
+ ``'a/b'``、``'a:b'``、``'a\tb'`` 全部变 ``'ab'``,``':::'`` 与
160
+ ``'///'`` 全部变 ``'untitled'``。本函数的输出会拼进 job_id → uid,
161
+ 两个不同任务净化后撞同一 uid 时,wall 去重会把后到任务判定为已完成
162
+ 而静默吞掉(数据丢失且无任何报错)。转义保留全部信息,从根上消除
163
+ 这类碰撞;仅空串输入无信息可保留,返回 ``'untitled'`` 兜底。
164
+
165
+ 超长输入(转义后超 ``max_len``):截断 + 8 位 SHA256 指纹后缀——纯
166
+ 截断同样破坏单射(前缀相同的长输入会相撞),指纹使截断碰撞概率可
167
+ 忽略(与 sanitize_content_id 的超长策略一致)。
168
+ """
169
+ text = str(value)
170
+ if not text:
171
+ return "untitled"
172
+ escaped = _escape_job_component(text)
173
+ if len(escaped) <= max_len:
174
+ return escaped
175
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
176
+ base_limit = max(max_len - 9, 0) # "_" 分隔符 + 8 位指纹;极小 max_len 下退化为仅指纹
177
+ return f"{escaped[:base_limit]}_{digest}"
178
+
179
+
180
+
181
+ # 文件系统瞬态注册(转码等本地 IO 重的消费方典型需求)
182
+
183
+
184
+ # 默认注册为瞬态的文件系统异常(磁盘满/只读/IO 抖动等环境问题重试合理)
185
+ _DEFAULT_TRANSIENT_FS = (PermissionError, BlockingIOError, ConnectionResetError)
186
+
187
+
188
+ def register_file_transients(
189
+ pipeline: TaskLite,
190
+ *,
191
+ classes: Sequence[type] = _DEFAULT_TRANSIENT_FS,
192
+ message: str = "文件系统异常已注册为瞬态(可重试)",
193
+ ) -> None:
194
+ """把文件系统环境错误注册为 pipeline 瞬态异常。
195
+
196
+ 注意:``FileNotFoundError`` 是确定性错误(源文件消失重试无意义),默认
197
+ 不注册;需要时调用方可显式传入。
198
+ """
199
+ for cls in classes:
200
+ pipeline.register_transient_exception(cls)
201
+ import logging
202
+ logging.getLogger("tasklite").info(
203
+ f"{message}: {', '.join(c.__name__ for c in classes)}"
204
+ )
tasklite/py.typed ADDED
File without changes
@@ -0,0 +1,6 @@
1
+ """Utility functions for tasklite."""
2
+ from .validation import validate_payload
3
+
4
+ __all__ = [
5
+ "validate_payload",
6
+ ]
tasklite/utils/ipc.py ADDED
@@ -0,0 +1,88 @@
1
+ """文件级 IPC 的写入侧:输入/输出/信号声明的路径构造与追加落盘。
2
+
3
+ 三个 ``append_*`` 与 ``*_path`` 构造函数原属 engine/executor;
4
+ ``TaskContext``(models 层)在声明 API 中调用它们落盘——为消除
5
+ models→engine 的上层引用,下沉到 utils(仅依赖 jsonutil/lockfile,
6
+ 无反向依赖)。executor 侧的读取函数(``read_inputs``/
7
+ ``read_outputs``/``read_signals``)经本模块的路径构造函数共享同一
8
+ 文件名规则(``{safe_uid_filename(uid)}.{inputs|outputs|signals}.jsonl``)。
9
+ """
10
+
11
+ from pathlib import Path
12
+
13
+ from .jsonutil import dumps
14
+ from .lockfile import safe_uid_filename
15
+
16
+ # 声明/信号文件的扩展名
17
+ _SIGNALS_SUFFIX = ".signals.jsonl"
18
+ _OUTPUTS_SUFFIX = ".outputs.jsonl"
19
+ _INPUTS_SUFFIX = ".inputs.jsonl"
20
+
21
+
22
+ def signals_path(ipc_dir, uid: str) -> Path:
23
+ """某个 job 的 suspend 信号文件路径(追加 JSONL)。"""
24
+ return Path(ipc_dir) / f"{safe_uid_filename(uid)}{_SIGNALS_SUFFIX}"
25
+
26
+
27
+ def outputs_path(ipc_dir, uid: str) -> Path:
28
+ """某个 job 的已声明输出文件路径(追加 JSONL)。"""
29
+ return Path(ipc_dir) / f"{safe_uid_filename(uid)}{_OUTPUTS_SUFFIX}"
30
+
31
+
32
+ def inputs_path(ipc_dir, uid: str) -> Path:
33
+ """输入声明的落盘路径:``{uid}.inputs.jsonl``。"""
34
+ return Path(ipc_dir) / f"{safe_uid_filename(uid)}{_INPUTS_SUFFIX}"
35
+
36
+
37
+ def append_input(ipc_dir, uid: str, entry: dict) -> None:
38
+ """追加一条输入声明到落盘文件(handler 子进程内调用)。
39
+
40
+ entry 形如 ``{"path": ..., "kind": "file", "size": ..., "mtime_ns": ...}``
41
+ 或 ``{"path": ..., "kind": "uri", "uri_fingerprint": ...}``。失败静默
42
+ (输入声明只影响可追溯性与 on_input_change 比对,丢失不破坏执行)。
43
+ """
44
+ path = inputs_path(ipc_dir, uid)
45
+ try:
46
+ path.parent.mkdir(parents=True, exist_ok=True)
47
+ with open(path, "a", encoding="utf-8") as f:
48
+ f.write(dumps(entry) + "\n")
49
+ f.flush()
50
+ except OSError:
51
+ pass
52
+
53
+
54
+ def append_output(ipc_dir, uid: str, out_path: str, cleanup: bool, kind: str = "output") -> None:
55
+ """追加一条输出声明到落盘文件(handler 子进程内调用)。
56
+
57
+ 失败静默(尽力而为):声明丢失不仅影响失败清理(输出残留),还影响
58
+ **成功路径的输出存在性校验**(executor 侧读 outputs.jsonl 校验
59
+ handler 声明的产物)——写入失败通常伴随更严重的磁盘故障(结果文件
60
+ 同样写不成功 → NO_IPC_RESULT 可见失败),fail-loud 会改变 handler
61
+ 语义(声明失败 = 任务失败),故保持静默。
62
+
63
+ ``kind`` 区分 ``"output"``(最终产物:成功校验存在、失败按 cleanup
64
+ 删除)与 ``"cache"``(临时文件:成功跳过存在性校验且尝试删除、
65
+ 失败无条件删除)。
66
+ """
67
+ path = outputs_path(ipc_dir, uid)
68
+ try:
69
+ path.parent.mkdir(parents=True, exist_ok=True)
70
+ with open(path, "a", encoding="utf-8") as f:
71
+ f.write(dumps(
72
+ {"path": out_path, "cleanup": bool(cleanup), "kind": kind},
73
+ ) + "\n")
74
+ f.flush()
75
+ except OSError:
76
+ pass
77
+
78
+
79
+ def append_signal(ipc_dir, uid: str, r_name: str, secs: float) -> None:
80
+ """追加一条 suspend 信号到 signals 文件(进程死文件仍在,不丢)。"""
81
+ path = signals_path(ipc_dir, uid)
82
+ try:
83
+ path.parent.mkdir(parents=True, exist_ok=True)
84
+ with open(path, "a", encoding="utf-8") as f:
85
+ f.write(dumps({"suspend": [r_name, secs]}) + "\n")
86
+ f.flush()
87
+ except OSError:
88
+ pass # 尽力而为:信号丢失不致命(resource_suspensions 兜底通道)
@@ -0,0 +1,65 @@
1
+ """JSON 序列化统一出口(契约:序列化一致性)。
2
+
3
+ 所有落盘/传输 JSON 必须经本模块的 ``dumps``/``loads``:
4
+
5
+ - ``dumps``:强制 ``allow_nan=False``——拒绝 NaN/Infinity 写出非标准 JSON
6
+ token(NaN 落盘后读取端拒绝加载 → 启动即崩)。
7
+ - ``loads``:``parse_constant`` 拒绝 NaN/Infinity/-Infinity 字面 token;
8
+ ``parse_float`` 拒绝溢出为无穷的浮点字面量(如 ``1e400``——不经过
9
+ parse_constant,默认解析静默产出 inf,下游 ``dumps(allow_nan=False)``
10
+ 回写时才炸或比较语义失真),且两类拒绝都抛 ``json.JSONDecodeError``
11
+ (非裸 ``ValueError``)——调用方既有的 ``except json.JSONDecodeError``
12
+ 分支(sqlite_backend.load_* 等)才能真正捕获;裸 ValueError 会逃逸
13
+ 既有 except 分支。
14
+
15
+ 静态契约:tests 扫描源码断言本模块之外无裸 ``json.dumps``/``json.loads``
16
+ (豁免:``models/state.py`` 的 hash 计算——非落盘/传输用途)。
17
+ """
18
+ import json
19
+ import math
20
+
21
+
22
+ def _reject_constant(name: str):
23
+ """parse_constant 回调:拒绝 NaN/Infinity,抛 JSONDecodeError。
24
+
25
+ JSONDecodeError 是 ValueError 子类——调用方按「损坏数据」处理的分支
26
+ (``except json.JSONDecodeError``)能捕获;裸 ValueError 会逃逸崩溃。
27
+ """
28
+ raise json.JSONDecodeError(
29
+ f"JSON constant {name!r} is not allowed (NaN/Infinity rejected)", "", 0
30
+ )
31
+
32
+
33
+ def _finite_float(s: str) -> float:
34
+ """parse_float 回调:拒绝溢出为 ±inf 的浮点字面量。
35
+
36
+ ``1e400`` 这类合法语法的浮点 token 不经过 parse_constant,默认解析
37
+ 静默产出 ``float('inf')``——与 NaN 同样破坏 dumps 回写与数值比较。
38
+ 统一按损坏数据拒绝(JSONDecodeError)。
39
+ """
40
+ val = float(s)
41
+ if not math.isfinite(val):
42
+ raise json.JSONDecodeError(
43
+ f"JSON float {s!r} overflows to non-finite (rejected)", "", 0
44
+ )
45
+ return val
46
+
47
+
48
+ def dumps(obj) -> str:
49
+ """序列化(ensure_ascii=False 保中文可读 + allow_nan=False 拒 NaN)。"""
50
+ return json.dumps(obj, ensure_ascii=False, allow_nan=False)
51
+
52
+
53
+ def dump(obj, fp):
54
+ """序列化到文件对象(与 dumps 同契约)。"""
55
+ json.dump(obj, fp, ensure_ascii=False, allow_nan=False)
56
+
57
+
58
+ def loads(s: str):
59
+ """反序列化(拒绝 NaN/Infinity/溢出浮点,抛 JSONDecodeError)。"""
60
+ return json.loads(s, parse_constant=_reject_constant, parse_float=_finite_float)
61
+
62
+
63
+ def load(fp):
64
+ """从文件对象反序列化(同 loads 的拒绝语义)。"""
65
+ return json.load(fp, parse_constant=_reject_constant, parse_float=_finite_float)
@@ -0,0 +1,154 @@
1
+ """跨平台文件锁 + uid→文件名安全映射(flock 孤儿探测)。
2
+
3
+ 设计约束:
4
+ - **worker 持锁、主进程仅探测**——锁的生命周期 = 执行体生命周期。主进程
5
+ 持锁会在其崩溃时释放(fd 关闭 → 内核释放 flock),孤儿 worker 仍活着
6
+ 锁却空闲 → 新 run 探测通过 → 双跑(正是要防的场景)。
7
+ - 锁文件 `{uid}.lock` 永不删除(unlink 后新进程 create 同名文件拿到的是
8
+ 新 inode 的锁,与旧持锁者不互斥——经典 unlink-recreate 竞争)。空文件
9
+ 累积可接受,pipeline 停止时可整目录离线清理。
10
+ - uid 含 ``::``(Windows 文件名禁 ``:``)→ 统一转义为安全字符,供锁文件
11
+ 与 executor 的 result/signals/outputs 文件名共享(不得另造一套映射;
12
+ 同时保证 fence 结果文件名的 Windows 兼容)。
13
+ """
14
+ import os
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+ # uid → 文件名安全形式:task_type::job_id → task_type%3A%3Ajob_id
20
+ # (百分号编码 `::`,先转义 `%` 保证单射——见 safe_uid_filename docstring)
21
+ _UID_ESCAPE = "::"
22
+ _UID_ESCAPED = "%3A%3A"
23
+ _PERCENT_ESCAPE = "%"
24
+ _PERCENT_ESCAPED = "%25"
25
+ # 文件系统危险字符——路径分隔符(POSIX `/`、Windows `\`)、
26
+ # NUL(os.open 拒绝)、glob 元字符(`*?[]` 会注入 _iter_stale_result_paths 的
27
+ # glob 匹配、跨 uid 删除他人结果文件)。全部转义为 %XX 保持单射可逆。
28
+ # 单冒号 `:` 同样加入——Windows 文件名禁 `:`(NTFS 保留
29
+ # 字符),job_id 只禁 `::` 可合法含单冒号(如 "id:with:colons"),不转义则
30
+ # Windows 派发路径非法;`::` 由 _UID_ESCAPED 替换处理(单冒号先于 `::`
31
+ # 替换逐字符转义,二者结果一致:`::` → `%3A%3A`,单射保持)。
32
+ _FS_ESCAPE_CHARS = {
33
+ "/": "%2F",
34
+ "\\": "%5C",
35
+ "\x00": "%00",
36
+ "*": "%2A",
37
+ "?": "%3F",
38
+ "[": "%5B",
39
+ "]": "%5D",
40
+ ":": "%3A",
41
+ }
42
+
43
+
44
+ def safe_uid_filename(uid: str) -> str:
45
+ """把 job uid 映射为对任意文件系统安全(无 ``:`` 等保留字符)的文件名。
46
+
47
+ 与 executor 的 result/signals/outputs 路径函数共享——统一映射,不得
48
+ 另造一套。
49
+
50
+ 直接 ``uid.replace("::", "_%3A%3A_")`` 非单射——
51
+ job_id 可合法含 ``%``,当 job_id 恰含字面 ``_%3A%3A_`` 时(如
52
+ ``t::x::y`` 与 ``t::x_%3A%3A_y``)两个不同 uid 映射到同一文件名,
53
+ 锁文件/signals/outputs/结果文件全碰撞。本实现先转义 ``%`` 为
54
+ ``%25`` 再转义 ``::``——编码序列中的 ``%`` 永不与用户输入的
55
+ ``%`` 混淆(后者已被 %25 吸收),映射单射且可逆。
56
+
57
+ job_id 只禁止 ``::``,可含 ``/``、``..``、
58
+ ``*`` 等——若不转义,uid 派生的 IPC 文件路径可逃逸 state_dir
59
+ (``os.open`` 创建/``os.replace`` 覆盖/``unlink`` 删除任意路径),
60
+ 且 ``t::a//b`` 与 ``t::a/b`` 在文件系统级碰撞。转义全部文件系统
61
+ 危险字符后:映射仍单射(%XX 可逆),且派生路径不含分隔符——
62
+ ``..`` 无法成为路径组件、glob 元字符不参与匹配。
63
+ """
64
+ escaped = uid.replace(_PERCENT_ESCAPE, _PERCENT_ESCAPED)
65
+ for ch, enc in _FS_ESCAPE_CHARS.items():
66
+ escaped = escaped.replace(ch, enc)
67
+ return escaped
68
+
69
+
70
+ def _lock_path(ipc_dir: str, uid: str) -> Path:
71
+ return Path(ipc_dir) / f"{safe_uid_filename(uid)}.lock"
72
+
73
+
74
+ def try_acquire_lock(ipc_dir: str, uid: str, *, timeout: float = 0.0) -> Optional[int]:
75
+ """尝试获取 ``{uid}.lock`` 排他锁(非阻塞或带超时)。
76
+
77
+ Returns:
78
+ 成功返回 fd(调用方必须 ``release_lock``);**仅锁被其他执行体占用**
79
+ 时返回 None。锁文件**创建后不删除**。
80
+
81
+ Raises:
82
+ OSError: 锁文件打不开/建不出(权限、磁盘满等环境故障)——与
83
+ 「锁被占」(瞬态、可 defer 重试)语义不同,不得混入 None:
84
+ 否则调用方会把环境故障误当锁冲突静默重试,真实错误被吞。
85
+
86
+ POSIX:``flock(LOCK_EX | LOCK_NB)``。Windows:``msvcrt.locking``
87
+ (``LK_NBLCK``,锁 offset 0 的 1 字节)。``timeout > 0`` 时以短间隔
88
+ 轮询(worker 入口持锁允许 ≤2s 短等待)。
89
+ """
90
+ path = _lock_path(ipc_dir, uid)
91
+ path.parent.mkdir(parents=True, exist_ok=True)
92
+ fd = os.open(str(path), os.O_RDWR | os.O_CREAT, 0o644)
93
+ deadline = None if timeout <= 0 else time.monotonic() + timeout
94
+ while True:
95
+ if _try_lock_fd(fd):
96
+ return fd
97
+ if deadline is None:
98
+ # 无 timeout = 纯非阻塞语义(主进程探测/单次尝试):失败即返回
99
+ os.close(fd)
100
+ return None
101
+ if time.monotonic() >= deadline:
102
+ os.close(fd)
103
+ return None
104
+ time.sleep(0.1)
105
+
106
+
107
+ def _try_lock_fd(fd: int) -> bool:
108
+ """对已打开的 fd 尝试非阻塞排他锁(平台分派)。"""
109
+ if os.name == "nt":
110
+ import msvcrt
111
+ try:
112
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1) # 锁 offset 0 的 1 字节
113
+ return True
114
+ except OSError:
115
+ return False
116
+ import fcntl
117
+ try:
118
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
119
+ return True
120
+ except OSError:
121
+ return False
122
+
123
+
124
+ def release_lock(fd: int) -> None:
125
+ """释放并关闭锁 fd。POSIX 解锁;Windows 移动文件指针到 offset 0 后解锁。"""
126
+ if fd is None:
127
+ return
128
+ try:
129
+ if os.name == "nt":
130
+ import msvcrt
131
+ os.lseek(fd, 0, os.SEEK_SET)
132
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
133
+ else:
134
+ import fcntl
135
+ fcntl.flock(fd, fcntl.LOCK_UN)
136
+ except OSError:
137
+ pass
138
+ try:
139
+ os.close(fd)
140
+ except OSError:
141
+ pass
142
+
143
+
144
+ def probe_lock(ipc_dir: str, uid: str) -> bool:
145
+ """主进程探测:非阻塞试锁,成功即释放,返回「无其他执行体持锁」。
146
+
147
+ 仅用于判断孤儿 worker 是否存活——探测成功释放锁,
148
+ 不持有。探测失败(孤儿持锁)→ 调用方 requeue + 短退避。
149
+ """
150
+ fd = try_acquire_lock(ipc_dir, uid)
151
+ if fd is None:
152
+ return False
153
+ release_lock(fd)
154
+ return True