Compare commits

...

4 Commits

Author SHA1 Message Date
ericwyuan
deaebf22b4 fix(fam-edge): 生产者扫描跳过已被 DiskGuard 清理的文件
生产者列目录之后、读 mtime 之前,DiskGuard 可能刚好把那个文件删了(两个后台线程
的正常竞态),os.path.getmtime 抛 FileNotFoundError。代价不是少处理一个文件,而是
**整轮扫描中断**——排在后面的新素材本轮全都登记不上,要等下一轮。

线上日志:生产者扫描异常: [Errno 2] No such file or directory:
/opt/fam-edge/gdrive_videos/20260911PM/Generic_ONVIF-001-20260911-234730-...mp4
(01:30:01 抛出,同一秒 DiskGuard 正在清理那批文件)

改成捕获 OSError 跳过该文件继续扫。新增 tests/test_video_queue.py:被删的跳过且
后面的照常登记、仍在写入的(mtime 太新)依然跳过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:35:15 +08:00
ericwyuan
3b5f7db51d fix(fam-edge): SQLite 改每线程一条连接,修好 DiskGuard 等一切并发写
OracleDB 原来在 __init__ 里建一条 check_same_thread=False 的连接给全进程共用,
而 VideoQueue / PersonService / DiskGuard 三个后台线程 + gunicorn 的请求线程都在
并发读写它。sqlite3 的连接对象本来就不是可并发共享的,事务状态互相踩踏,线上
累计刷出三类错误(同一个根因):

  database is locked                            71604 次
  cannot start a transaction within a transaction  378 次
  no more rows available                           92 次(堆栈落在 commit())

最严重的后果是磁盘守护形同虚设:DiskGuard 的清理被打断 2837 次,成功仅 128 次,
剩余空间在 2.7GB 和 16GB 之间来回荡——赶上 rclone 集中下载(实测 5 分钟写入
12.6GB)就掉进危险区。NAS 推来的运动事件被 500 打回也是它(失败批次不推进游标,
下一轮补推,所以没丢事件)。

改法:_conn 改成 @property,从 threading.local() 取当前线程的连接,没有就新建
(WAL + busy_timeout=10000)。做成 property 是因为外部调用方(api_gateway 的
activity 端点)也在直接用 db._conn.execute(...),这样全部现有调用点原样工作。
close() 相应改成收掉所有线程开过的连接。_write_lock 保留,复合写语义不变。

测试:新增 8 线程 × 25 轮并发读写、close 收连接两个用例;在旧代码上稳定复现
同族错误 cannot commit transaction - SQL statements in progress。

线上验证:重启后 DiskGuard 立刻跑通一轮(清 40 个文件,剩余回到 15.3GB),
三类 SQLite 错误在重启后的日志里均为 0。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 09:34:24 +08:00
ericwyuan
a494d60361 docs: 补入 2026-09-03 工作日志(同步 1062 复发 + 甲骨文过载)
这份日志一直没入库,同目录 8/20 到 8/31 的都在。内容是 9/3 那次排查记录:
Oracle 侧 SQLite id 整体重排导致 sync_videos/sync_people 二次 1062、游标卡死
(修复即 0238030),以及同一天甲骨文整机过载挂死、SSH 握手被拒的环境问题。

后半段那个「Oracle 盒子过载」当时记为未解决,现在有了后续:运动事件推送从那天
起断流十天,直到 2026-09-13 全量迁云时才发现并恢复(见 PROGRESS 同日条目)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:55:08 +08:00
ericwyuan
da60365214 feat(fam-notifier): 补开机自启脚本,NAS 重启后推送不再静默断流
NAS 上没有 systemd,rc.d 是唯一的守护手段。旁边 frpc / nas-monitor / comic-api
都有 S99 脚本,唯独这个推送进程没有——它一旦随重启消失,运动事件就悄无声息地
断流,而且不打开网站根本发现不了。2026-09-04 到 09-12 断档整整十天就是这个剧本
(当时它还在 fam-core 里,fam-core 挂了没人重启)。

- 照抄 S99nas-monitor.sh 的写法:等 volume1 挂载就绪(最多 90s)、pidfile 判重
- 以 ericwyuan 而非 root 运行,保持 logs/ 与 data/cursor.json 属主跟手动启动一致
- DSM 上没有 pgrep,用 grep 字符类找 pid
- su 自身的 stdio 也断开:否则通过 SSH 执行时命令跑完了会话却不退出(实测踩到)

已装到 NAS 的 /usr/local/etc/rc.d/ 并验证 start/stop/restart/status;
restart 后游标从本地文件正确读回(33081),没有被重置成 SS 当前最大 id。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 08:35:36 +08:00
8 changed files with 332 additions and 6 deletions

View File

@@ -0,0 +1,39 @@
# 2026-09-03 同步 1062 复发 + Oracle 盒子过载
## 问题
用户报 sync 状态卡在「同步中」+ `1062 Duplicate entry '...mp4' for key 'filename'`
游标停在 `2026-09-02 13:55:21`
## 根因(与 8/29 identity_map 同一类)
Oracle FAM-Edge 的 SQLite 视频/人物 id 整体重排2303 段 → 3408/4600+ 段),
NAS 镜像表 `sync_videos`/`sync_people` 旧 upsert 以 Oracle id 当主键插入,
与已存在的同 `filename`/`label` 行在 UNIQUE 键上二次冲突 → 1062整批中止、游标不推进。
量化:本次积压 348 videos 中 **114 行**会 10629 people 中 **6 行**会 1062。
events/model_calls 仅 PK idON DUPLICATE 按 id 更新,不会 1062→ 无需改。
## 修复(已部署 NAS + 提交推送 0238030
- `upsert_sync_videos`:改以 `filename` 业务键去重,命中就地 UPDATE 保留 NAS 原 id
(防 sync_events/model_calls/identity_map 的 video_id 外键失效Oracle id 落
`oracle_id` 列溯源;未命中插入优先用 Oracle id 对齐子表引用,主键冲突回退自增。
- `upsert_sync_people`:同模式,以 `label` 业务键去重people.id 无外键引用)。
- `scripts/ddl.sql`sync_videos/sync_people 的 id 改 NAS 本地自增 + 新增 oracle_id 列。
- NAS 现网迁移:两表加 oracle_id、回填 oracle_id=id、id 改 AUTO_INCREMENT重启。
- 验证:单批补拉 videos+348 events+1017 people+9 model_calls+500 identity_map+238
游标 → `2026-09-03 08:51:11`,无 1062。目标行保留 id=2303、oracle_id=3408。
## 第二个问题Oracle 盒子过载/挂死(未解决,环境层)
NAS 日志 08:5108:56 持续:`推送运动事件到 Oracle 失败 Connection reset` /
`500 {"error":"database is locked"}` / `RemoteDisconnected`
FAM-Edge /health 先 200status ok后变 HTTP 000最终整盒 129.146.26.249 全暗
:80/:5000/:5300/:7000/:8123 均 000但 TCP :22/:5000 端口 OPEN、SSH 握手被拒
kex 阶段 Connection closed→ 进程卡死/资源耗尽4C23G 上 FAM-Edge 分析管线 +
ai-gateway + garmin + auth-hub + frps + caddy 挤一起SQLite 锁竞争 + 可能 OOM
**SSH 连不进去,无法远程修复。** 需用户在 Oracle Cloud 控制台重启 VM或等其自恢复。
恢复后 NAS 的 oracle_sync 后台线程30min 周期)会自动从游标 08:51:11 续拉,
motion_notifier 也会恢复推送。
## 经验
- 所有以 Oracle id 为镜像主键的表迟早会因 Oracle 库重建/重排踩坑。已修:
identity_map(8/29)、videos+people(9/3)。events/model_calls 目前 PK-only 安全,
但若将来 Oracle 重建导致子表引用错位,需同样改造(加 oracle_id + 业务键)。
- Oracle 盒子服务过多挤在 4C23G建议拆分或给 FAM-Edge SQLite 开 WAL + 限速。

View File

@@ -612,3 +612,39 @@ people 59外网 `/` `/timeline` 200、`/login` 302、`/api/*` 未登录 401
**待办**NAS 侧部署 fam-notifier只能用户手动密码登录chat_history 一次性迁移 **待办**NAS 侧部署 fam-notifier只能用户手动密码登录chat_history 一次性迁移
`fam-core/scripts/import_chat_history.py`幂等frpc.toml 里的 8000 映射可删。 `fam-core/scripts/import_chat_history.py`幂等frpc.toml 里的 8000 映射可删。
## 修复 fam-edge 多线程共用 SQLite 连接2026-09-13
**触发**:用户问「甲骨文剩余硬盘小于 10G 会删东西的逻辑怎么没了?」。查下来逻辑一直在、
也没被迁云动过(`disk_guard.py` 最后一次改动还是 8/28 新增它那次),但它 95% 的检查在空转:
```
DiskGuard「本轮清理完成」 128 次
DiskGuard「检查异常」 2837 次 ← 22 倍
```
失败原因全是 `cannot start a transaction within a transaction`。表现就是磁盘剩余空间在
2.7GB 和 16GB 之间来回荡——清理能不能成功全靠运气,赶上 rclone 集中下载
(实测 5 分钟写入 12.6GB)就掉进危险区。
**根因**`OracleDB.__init__` 建一条 `sqlite3.connect(check_same_thread=False)` 的连接
给全进程共用,而 VideoQueue / PersonService / DiskGuard 三个后台线程 + gunicorn 的 4 个
请求线程都在并发读写它。sqlite3 的连接对象本来就不是可并发共享的,事务状态互相踩踏。
同一个根因在线上刷出三类错误,累计:`database is locked` 71604 次、
`cannot start a transaction within a transaction` 378 次、`no more rows available` 92 次
(后者堆栈落在 `self._conn.commit()`,是游标被别的线程重置的典型症状)。
运动事件推送被 500 打回也是它——NAS 侧失败批次不推进游标,下一轮补推,所以没丢事件。
**修复**`_conn` 改成 `@property`,从 `threading.local()` 取当前线程的连接,没有就新建
WAL + busy_timeout=10000`close()` 相应改成收掉所有线程开过的连接。因为外部调用方
(如 `api_gateway` 的 activity 端点)也在直接用 `db._conn.execute(...)`,做成 property
可以让全部现有调用点原样工作,不用逐个改。`_write_lock` 保留,复合写语义不变。
**测试**:新增 2 个用例8 线程 × 25 轮并发读写、close 要收掉所有连接)。在旧代码上
稳定复现同族错误 `cannot commit transaction - SQL statements in progress`,修复后通过;
fam-edge 全套 159 个测试绿。
**同批修掉的第二个竞态**:生产者列目录之后、读 mtime 之前DiskGuard 可能刚好把那个
文件清掉(两个后台线程的正常竞态),`os.path.getmtime` 抛 FileNotFoundError代价是
**整轮扫描中断**——排在后面的新素材本轮全都登记不上。改成捕获 OSError 跳过该文件,
新增 `fam-edge/tests/test_video_queue.py` 两个用例覆盖(被删的跳过 / 仍在写入的照样跳过)。

View File

@@ -724,6 +724,7 @@ print('NVIDIA NIM 连接成功:', response.choices[0].message.content)
- NAS scp 子系统被禁用,同样用 stdin 管道传文件 - NAS scp 子系统被禁用,同样用 stdin 管道传文件
- **Oracle fam-edge 由 systemd `fam-edge.service` 守护Restart=always**:部署代码后必须 `sudo systemctl restart fam-edge`;手动 `setsid` 启动会和守护打架(端口 `Connection in use` - **Oracle fam-edge 由 systemd `fam-edge.service` 守护Restart=always**:部署代码后必须 `sudo systemctl restart fam-edge`;手动 `setsid` 启动会和守护打架(端口 `Connection in use`
- NAS 远端 kill gunicorn 用 `ps aux | grep "[f]am-core/venv/bin/gunicorn"` 字符类技巧pkill/pgrep 会匹配 SSH 自身命令行导致断连) - NAS 远端 kill gunicorn 用 `ps aux | grep "[f]am-core/venv/bin/gunicorn"` 字符类技巧pkill/pgrep 会匹配 SSH 自身命令行导致断连)
- **sqlite3 连接不能跨线程共享**2026-09-13 修):`OracleDB` 原来在 `__init__` 里建一条 `check_same_thread=False` 的连接给全进程用VideoQueue / PersonService / DiskGuard 三个后台线程加 gunicorn 请求线程并发读写它,事务状态互相踩踏,线上累计刷出 `database is locked` 71604 次、`cannot start a transaction within a transaction` 378 次、`no more rows available` 92 次DiskGuard 的清理被打断 2837 次,磁盘守护形同虚设(剩余空间在 2.7G 和 16G 之间来回荡)。改成 `threading.local()` 每线程一条连接后WAL 下读不互斥、写由 SQLite 自己排队。**新增后台线程时不要再去共用某一条连接对象**
- fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app``start_core.sh` 会 source 仓库根 `.env` 注入 `DSM_*/ORACLE_SYNC_TOKEN` - fam-core 启动模块路径是 `src.fam_core.app:app`(不是 `fam_core.app:app``start_core.sh` 会 source 仓库根 `.env` 注入 `DSM_*/ORACLE_SYNC_TOKEN`
- Edge 单 worker 处理任务期间 `/health` 可能不响应,属正常 - Edge 单 worker 处理任务期间 `/health` 可能不响应,属正常
- **运动数据清理**:切换架构/重新提取时清 Oracle `videos/events/people` + `motion_clips/`(保留 `ss_motion_events` 与素材)与 NAS `sync_*` 镜像,重启两端自动重新分割分析 - **运动数据清理**:切换架构/重新提取时清 Oracle `videos/events/people` + `motion_clips/`(保留 `ss_motion_events` 与素材)与 NAS `sync_*` 镜像,重启两端自动重新分割分析

View File

@@ -33,13 +33,42 @@ class OracleDB:
def __init__(self, db_path: str): def __init__(self, db_path: str):
os.makedirs(os.path.dirname(db_path), exist_ok=True) os.makedirs(os.path.dirname(db_path), exist_ok=True)
self.db_path = db_path self.db_path = db_path
self._conn = sqlite3.connect(db_path, check_same_thread=False) self._local = threading.local() # 每线程一条连接,见下面的 _conn
self._conn.row_factory = sqlite3.Row self._all_conns = [] # 仅供 close() 收尾
self._conn.execute("PRAGMA journal_mode=WAL") self._conns_lock = threading.Lock()
self._conn.execute("PRAGMA busy_timeout=10000")
self._write_lock = threading.Lock() # 复合写(如 DELETE+INSERT+commit串行化 self._write_lock = threading.Lock() # 复合写(如 DELETE+INSERT+commit串行化
self._init_schema() self._init_schema()
@property
def _conn(self) -> sqlite3.Connection:
"""当前线程的连接2026-09-13 从"全进程共用一条"改成每线程一条)。
原来是 __init__ 里建一条 `check_same_thread=False` 的连接给所有线程共用:
VideoQueue / PersonService / DiskGuard 三个后台线程,加上 gunicorn 的请求
线程,并发读写同一个连接对象,事务状态互相踩踏。线上长期刷三类报错,
全是这一个根因:
- `cannot start a transaction within a transaction`:一个线程的事务还
开着另一个线程又要开——DiskGuard 的清理被打断 2837 次,磁盘守护基本
靠运气生效(表现为剩余空间在 2.7G 和 16G 之间来回荡)
- `no more rows available`commit 时游标已被别的线程重置87 次
- `database is locked`每小时上百次NAS 推来的运动事件被 500 打回
sqlite3 的连接本来就不是可并发共享的对象。改成各线程各拿一条之后WAL 下
多连接读不互斥,写由 SQLite 自己排队busy_timeout 兜底等 10 秒),而
`_write_lock` 继续保证"复合写"在本进程内串行,语义不变。
"""
conn = getattr(self._local, 'conn', None)
if conn is None:
conn = sqlite3.connect(self.db_path, timeout=10, check_same_thread=False)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=10000")
self._local.conn = conn
with self._conns_lock:
self._all_conns.append(conn)
return conn
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _init_schema(self): def _init_schema(self):
c = self._conn c = self._conn
@@ -1078,4 +1107,12 @@ class OracleDB:
self._conn.commit() self._conn.commit()
def close(self): def close(self):
self._conn.close() """关掉所有线程开过的连接(不只当前线程这一条)。"""
with self._conns_lock:
conns, self._all_conns = self._all_conns, []
for c in conns:
try:
c.close()
except sqlite3.Error:
pass
self._local = threading.local()

View File

@@ -84,7 +84,15 @@ class VideoQueue:
if row is None: if row is None:
# 新文件:先过 mtime 稳定窗口 + 可解码校验,通过才登记入队;失败标记 invalid # 新文件:先过 mtime 稳定窗口 + 可解码校验,通过才登记入队;失败标记 invalid
if self.file_validate: if self.file_validate:
if time.time() - os.path.getmtime(path) < self.stable_window_sec: try:
mtime = os.path.getmtime(path)
except OSError:
# 列目录之后、读 mtime 之前DiskGuard 可能刚好把这个文件清掉了
# (两个后台线程的正常竞态)。跳过它就行——不 catch 的话整轮扫描
# 会被这一个文件中断,后面的新素材本轮都登记不上。
logger.info(f"文件已不在(可能刚被 DiskGuard 清理),跳过本轮: {fn}")
continue
if time.time() - mtime < self.stable_window_sec:
logger.info(f"文件仍在写入mtime 未稳定),跳过本轮: {fn}") logger.info(f"文件仍在写入mtime 未稳定),跳过本轮: {fn}")
continue continue
ok, verr, vmeta = validate_video(path) ok, verr, vmeta = validate_video(path)

View File

@@ -463,3 +463,63 @@ def test_delete_video_does_not_touch_ss_motion_events(tmp_path):
assert db.get_video_by_motion_event_id(555) is None assert db.get_video_by_motion_event_id(555) is None
row = db._conn.execute("SELECT * FROM ss_motion_events WHERE event_id=?", (555,)).fetchone() row = db._conn.execute("SELECT * FROM ss_motion_events WHERE event_id=?", (555,)).fetchone()
assert row is not None assert row is not None
# ---------------------------------------------------------------------------
# 并发2026-09-13修"全进程共用一条 sqlite 连接"的回归测试)
# ---------------------------------------------------------------------------
def test_concurrent_writes_from_many_threads(tmp_path):
"""多线程同时读写不能互相踩踏。
改成每线程一条连接之前,这里会稳定炸出三类错误之一:
`cannot start a transaction within a transaction` / `no more rows available`
/ `database is locked`——线上 DiskGuard 的清理就是被第一种打断了 2837 次。
"""
import threading
db = _db(tmp_path)
errors = []
rounds = 25
def writer(tid):
try:
for i in range(rounds):
db.record_activity('t%d' % tid, 'act%d' % i, 'detail')
db.set_cursor('cursor_t%d' % tid, str(i))
db.record_motion_events([{
'event_id': tid * 1000 + i, 'camera_id': 2, 'event_type': 10,
'start_time': 1789000000 + i, 'duration': 5,
'thumbnail_url': '',
}])
db.get_recent_activities(5)
db.get_motion_heartbeat_age_sec()
except Exception as e: # noqa: BLE001 —— 要把原始异常带出来看
errors.append(f"线程{tid}: {type(e).__name__}: {e}")
threads = [threading.Thread(target=writer, args=(t,)) for t in range(8)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=60)
assert not errors, "并发写出错:\n" + "\n".join(errors[:5])
assert db._conn.execute("SELECT COUNT(*) FROM ss_motion_events").fetchone()[0] == 8 * rounds
for tid in range(8):
assert db.get_cursor('cursor_t%d' % tid) == str(rounds - 1)
def test_close_releases_every_thread_connection(tmp_path):
"""close() 要收掉所有线程开过的连接,不只当前线程那一条。"""
import threading
db = _db(tmp_path)
db.record_activity('main', 'x', '')
def other():
db.record_activity('other', 'y', '')
t = threading.Thread(target=other)
t.start(); t.join()
assert len(db._all_conns) == 2 # 主线程 + 子线程各一条
db.close()
assert db._all_conns == []

View File

@@ -0,0 +1,73 @@
"""VideoQueue 生产者扫描的单测。
目前只覆盖一个点:生产者列目录之后、读 mtime 之前DiskGuard 可能刚好把文件清掉
(两个后台线程的正常竞态)。线上表现为 `生产者扫描异常: [Errno 2] No such file or
directory`,代价是**整轮扫描中断**——排在后面的新素材本轮全都登记不上,要等下一轮。
"""
import os
import pytest
from fam_edge import video_queue as vq_mod
from fam_edge.video_queue import VideoQueue
class _FakeDB:
"""只实现生产者路径上用到的方法。"""
def __init__(self):
self.registered = []
self.activities = []
def get_video_by_filename(self, fn):
return None
def ensure_video(self, fn, path, camera_name=None):
self.registered.append(fn)
return len(self.registered)
def set_video_file_status(self, vid, valid, err, meta=None):
pass
def record_activity(self, service, action, detail=''):
self.activities.append((service, action))
@pytest.fixture
def queue_with_two_files(tmp_path, monkeypatch):
monkeypatch.setattr(vq_mod, 'load_config', lambda: {
'gdrive_sync': {'local_dir': str(tmp_path), 'camera_name': '客厅'},
'video_processing': {'file_validate': True, 'stable_window_sec': 60},
})
(tmp_path / "a_被删掉的.mp4").write_bytes(b"x")
(tmp_path / "b_正常的.mp4").write_bytes(b"y")
db = _FakeDB()
q = VideoQueue(db)
monkeypatch.setattr(q, '_enqueue', lambda vid: None)
monkeypatch.setattr(vq_mod, 'validate_video', lambda p: (True, '', {'fps': 25}))
return q, db
def test_produce_skips_file_deleted_mid_scan_and_keeps_going(queue_with_two_files, monkeypatch):
"""被删掉的那个跳过,后面的照常登记——不能整轮中断。"""
q, db = queue_with_two_files
real_getmtime = os.path.getmtime
def fake_getmtime(path):
if 'a_被删掉的' in path:
raise FileNotFoundError(2, 'No such file or directory', path)
return real_getmtime(path) - 3600 # 早于稳定窗口,视为写入完成
monkeypatch.setattr(vq_mod.os.path, 'getmtime', fake_getmtime)
q._produce_once() # 不抛异常
assert db.registered == ["b_正常的.mp4"] # 被删的跳过,后面的没受影响
def test_produce_still_skips_files_being_written(queue_with_two_files, monkeypatch):
"""mtime 太新rclone 还在写)仍然要跳过,别把半成品入队。"""
q, db = queue_with_two_files
monkeypatch.setattr(vq_mod.os.path, 'getmtime', lambda p: __import__('time').time())
q._produce_once()
assert db.registered == []

View File

@@ -0,0 +1,72 @@
#!/bin/sh
# fam-notifier 开机自启DSM 没有 systemd/usr/local/etc/rc.d/ 是唯一的守护手段)
#
# 部署sudo cp 到 /usr/local/etc/rc.d/S99fam-notifier.sh && sudo chmod 755 同路径
#
# 为什么非有不可:这个进程是摄像头系统在 NAS 上唯一保留的东西(轮询
# Surveillance Station把运动事件单向推给甲骨文。它没起来的话事件就悄无声息
# 地断流——2026-09-04 到 09-12 断档整整十天,就是因为当时它还在 fam-core 里、
# fam-core 挂了没人重启,而且不打开网站根本发现不了。
#
# 以 ericwyuan 而不是 root 运行:保持 logs/ 和 data/cursor.json 的属主跟手动启动
# 时一致,免得 root 建出来的文件之后普通用户改不动。
RUN_USER=ericwyuan
APP_DIR=/volume1/web/sentinel-home-ai/fam-notifier
START="$APP_DIR/scripts/start_notifier.sh"
LOG="$APP_DIR/logs/start.log"
PIDFILE="$APP_DIR/fam-notifier.pid"
# DSM 上没有 pgrep用 grep 字符类找 pid[f] 写法避免匹配到 grep 自己)
find_pid() {
ps aux | grep "[f]am_notifier" | awk '{print $2}' | head -1
}
start() {
RUNNING=$(find_pid)
if [ -n "$RUNNING" ]; then
echo "fam-notifier 已在运行 (PID $RUNNING)"; echo "$RUNNING" > "$PIDFILE"; return 0
fi
# 等待数据卷 volume1 挂载就绪(最多 90 秒),避免开机时脚本早于卷挂载而静默失败
n=0
while [ ! -f "$START" ] && [ $n -lt 90 ]; do
sleep 1; n=$((n+1))
done
if [ ! -f "$START" ]; then
echo "等待 $START 超时(数据卷可能未挂载),启动放弃"; return 1
fi
# su 自身的 stdio 也要断开:否则通过 SSH 执行本脚本时,子进程继承了连接的管道,
# 命令跑完了 SSH 会话却迟迟不退出(开机时无所谓,手动敲的时候很烦)
su "$RUN_USER" -c "cd '$APP_DIR' && setsid nohup sh scripts/start_notifier.sh >> '$LOG' 2>&1 < /dev/null &" > /dev/null 2>&1 < /dev/null
sleep 4
PID=$(find_pid)
if [ -n "$PID" ]; then
echo "$PID" > "$PIDFILE"
echo "fam-notifier 已启动 (PID $PID)"
else
echo "启动失败,请查看 $LOG$APP_DIR/logs/fam-notifier.log"; return 1
fi
}
stop() {
if [ -f "$PIDFILE" ]; then kill "$(cat "$PIDFILE")" 2>/dev/null; rm -f "$PIDFILE"; fi
for p in $(ps aux | grep "[f]am_notifier" | awk '{print $2}'); do kill "$p" 2>/dev/null; done
echo "fam-notifier 已停止"
}
status() {
PID=$(find_pid)
if [ -n "$PID" ]; then
echo "运行中 (PID $PID)"
tail -3 "$APP_DIR/logs/fam-notifier.log" 2>/dev/null
else
echo "未运行"; return 1
fi
}
case "$1" in
start) start ;;
stop) stop ;;
restart) stop; sleep 2; start ;;
status) status ;;
*) echo "用法: $0 {start|stop|restart|status}"; exit 1 ;;
esac