From edce20c25d93936c6b734b022b97800911f917f8 Mon Sep 17 00:00:00 2001 From: Marin Wang Date: Fri, 13 Nov 2026 17:44:29 +0800 Subject: [PATCH 1/4] feat: maintain compressed logs after task completion --- docs/en_us/5.1-Troubleshooting.md | 7 +- ...56\351\242\230\345\217\215\351\246\210.md" | 7 +- source/MaaFramework/Tasker/Tasker.cpp | 1 + source/MaaUtils | 2 +- test/python/test_log_maintenance.py | 186 ++++++++++++++++++ 5 files changed, 198 insertions(+), 5 deletions(-) create mode 100644 test/python/test_log_maintenance.py diff --git a/docs/en_us/5.1-Troubleshooting.md b/docs/en_us/5.1-Troubleshooting.md index 8722d7ad3f..a7b4e68f45 100644 --- a/docs/en_us/5.1-Troubleshooting.md +++ b/docs/en_us/5.1-Troubleshooting.md @@ -25,14 +25,17 @@ Logs are critical for troubleshooting. Please provide the complete log file. By default, the log file is located at: `/debug/maafw.log` +Rotation is checked during initialization and flushes such as task startup, at a threshold of 128 MiB (not a continuous hard limit). Completed `maafw.bak..log` backups are compressed in the background to `.log.gz` using gzip level 1; the current `maafw.log` remains plain text. Initialization, rotation, and task completion trigger background maintenance; routine checks run at most once every five minutes. +Historical logs and `on_error/` screenshots are retained for seven days, and `vision/` drawings for three days. When these historical artifacts exceed a combined 1 GiB budget, vision drawings are removed first, followed by historical logs and finally error screenshots, oldest first within each category. The current log is excluded from this budget, and images are not recompressed. Save diagnostic files elsewhere if you need to keep them longer. + ### Before Uploading 1. Open the log file and scroll to the end to verify the timestamp matches when the issue occurred -2. If the file is noticeably short (may have been flushed), also upload `debug/maafw.bak.log` +2. If the file is noticeably short (may have rotated), also upload the matching `debug/maafw.bak.*.log.gz` files; uncompressed `.log` backups can also be uploaded directly ### Upload Requirements -- **Provide complete logs**: Upload the `.log` file directly; compress to `.zip` if too large +- **Provide complete logs**: Upload `.log` or `.log.gz` files directly; bundle them into `.zip` if needed - **Avoid excerpts**: Self-extracted log snippets often miss critical context - **Prefer clean logs**: If the issue is reproducible, consider deleting the original log and running once more to capture the latest problem log. This reduces noise and speeds up analysis. For intermittent issues, upload whatever you have - **Comparison logs**: If one operation works normally while another fails, upload both logs and rename them for clarity (e.g., `maa_ok.log` / `maa_fail.log`). This helps identify differences quickly diff --git "a/docs/zh_cn/5.1-\351\227\256\351\242\230\345\217\215\351\246\210.md" "b/docs/zh_cn/5.1-\351\227\256\351\242\230\345\217\215\351\246\210.md" index 9708a9b446..25afb76192 100644 --- "a/docs/zh_cn/5.1-\351\227\256\351\242\230\345\217\215\351\246\210.md" +++ "b/docs/zh_cn/5.1-\351\227\256\351\242\230\345\217\215\351\246\210.md" @@ -25,14 +25,17 @@ 若无特殊设置,日志文件位于:`<工作目录>/debug/maafw.log` +日志在初始化、任务开始等 flush 时机检查轮转,阈值为 128 MiB(并非实时硬上限)。已完成轮转的 `maafw.bak.<时间>.log` 会在后台使用 gzip level 1 压缩为 `.log.gz`,当前 `maafw.log` 保持文本格式。初始化、轮转及任务结束会触发后台维护;常规检查最多每 5 分钟一次。 +历史日志和 `on_error/` 截图保留 7 天,`vision/` 调试图片保留 3 天;这些历史产物合计超过 1 GiB 时,优先删除 `vision/` 图片,再删除历史日志,最后删除错误截图,各类内部按从旧到新的顺序清理。当前日志不计入此预算,图片不会重复压缩。请及时保存需要长期保留的诊断文件。 + ### 上传前请确认 1. 打开日志文件,翻到末尾检查时间戳是否为问题发生时间 -2. 若文件明显过短(可能已被 flush),请同时上传 `debug/maafw.bak.log` +2. 若文件明显过短(可能已轮转),请同时上传对应时段的 `debug/maafw.bak.*.log.gz`;尚未压缩的 `.log` 备份也可直接上传 ### 上传要求 -- **提供完整日志**:直接上传 `.log` 文件,体积过大可压缩为 `.zip` +- **提供完整日志**:直接上传 `.log` 或 `.log.gz` 文件,体积过大可打包为 `.zip` - **避免截取片段**:自行截取的日志片段往往缺失关键上下文 - **尽量提供干净日志**:若问题可稳定复现,建议删除原日志后重新运行一次,上传最新的问题日志,避免大量无关日志干扰排查。偶发性问题无需强求,直接上传即可 - **对比日志**:若某操作正常、另一操作异常,建议同时上传正常和异常时的日志,并重命名以便区分(如 `maa_ok.log` / `maa_fail.log`),便于对比定位差异 diff --git a/source/MaaFramework/Tasker/Tasker.cpp b/source/MaaFramework/Tasker/Tasker.cpp index c1141012cd..6b55449ca0 100644 --- a/source/MaaFramework/Tasker/Tasker.cpp +++ b/source/MaaFramework/Tasker/Tasker.cpp @@ -363,6 +363,7 @@ bool Tasker::run_task(RunnerId runner_id, TaskPtr task_ptr) controller_->wait(controller_->post_inactive()); } + MAA_LOG_NS::Logger::get_instance().cleanup(); return ret; } diff --git a/source/MaaUtils b/source/MaaUtils index 55be777e32..9d7e8b1dd0 160000 --- a/source/MaaUtils +++ b/source/MaaUtils @@ -1 +1 @@ -Subproject commit 55be777e326db9419f45b4082e2bf2a1ed10e697 +Subproject commit 9d7e8b1dd096fbc6c595aa43053bc1d75dc23401 diff --git a/test/python/test_log_maintenance.py b/test/python/test_log_maintenance.py new file mode 100644 index 0000000000..4bf48ae354 --- /dev/null +++ b/test/python/test_log_maintenance.py @@ -0,0 +1,186 @@ +"""Exercise log rotation and background maintenance through the native library.""" + +import gzip +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import time +import unittest + + +MIB = 1024 * 1024 +DAY = 24 * 60 * 60 + + +class LogMaintenanceTest(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="maafw-logs-") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) / "日志" + self.root.mkdir() + + def write_file(self, name, content=b"diagnostic evidence\n", age_days=0, size=None): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as stream: + stream.write(content) + if size is not None: + stream.truncate(size) + modified = time.time() - age_days * DAY + os.utime(path, (modified, modified)) + return path + + def maintain(self, prelude=""): + # Process exit joins the native maintenance worker, avoiding sleeps and + # changes to the parent test process's global logging configuration. + result = subprocess.run( + [ + sys.executable, + "-c", + prelude + "import sys; from maa.tasker import Tasker; " + "assert Tasker.set_log_dir(sys.argv[1])", + str(self.root), + ], + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_gzip_round_trip_preserves_age_and_active_log(self): + content = ("识别日志\x00\r\n".encode() + bytes(range(256))) * 1024 + backup = self.write_file("maafw.bak.roundtrip.log", content, age_days=2) + modified = backup.stat().st_mtime + active = self.write_file("maafw.log", b"active log\n", age_days=10) + unrelated = self.write_file("other.log", age_days=10) + + self.maintain() + + archive = backup.with_suffix(".log.gz") + with gzip.open(archive, "rb") as stream: + self.assertEqual(stream.read(), content) + self.assertEqual(archive.read_bytes()[8], 4) # gzip's fastest-compression flag + self.assertAlmostEqual(archive.stat().st_mtime, modified, delta=2) + self.assertFalse(backup.exists()) + self.assertTrue(active.read_bytes().startswith(b"active log\n")) + self.assertTrue(unrelated.exists()) + self.assertFalse(list(self.root.glob("*.tmp"))) + + before = archive.read_bytes() + self.maintain() + self.assertEqual(archive.read_bytes(), before) + + def test_retention_and_images(self): + expired = [ + self.write_file("maafw.bak.old.log", age_days=8), + self.write_file("maafw.bak.old.log.gz", age_days=8), + self.write_file("maafw.bak.old.log.gz.interrupted.tmp", age_days=8), + self.write_file("vision/old.jpg", age_days=4), + self.write_file("on_error/old.png", age_days=8), + ] + retained = [ + self.write_file("vision/new.jpg", b"jpeg bytes", age_days=2), + self.write_file("on_error/recent.png", b"png bytes", age_days=6), + self.write_file("resource/template.png", b"user image", age_days=10), + ] + contents = {path: path.read_bytes() for path in retained} + + self.maintain() + + for path in expired: + self.assertFalse(path.exists(), str(path)) + for path, content in contents.items(): + self.assertEqual(path.read_bytes(), content) + + def test_budget_prefers_vision_and_then_oldest_logs(self): + vision = self.write_file("vision/recent.jpg", size=512 * MIB) + older = self.write_file("maafw.bak.older.log.gz", age_days=2, size=600 * MIB) + newer = self.write_file("maafw.bak.newer.log.gz", age_days=1, size=400 * MIB) + failure = self.write_file("on_error/failure.png", age_days=3, size=50 * MIB) + + self.maintain() + + self.assertFalse(vision.exists()) + self.assertFalse(older.exists()) + self.assertTrue(newer.exists()) + self.assertTrue(failure.exists()) + + def test_failed_compression_preserves_original(self): + original = self.write_file("maafw.bak.blocked.log", b"must survive\n") + original.with_suffix(".log.gz").mkdir() + + self.maintain() + + self.assertEqual(original.read_bytes(), b"must survive\n") + self.assertFalse(list(self.root.glob("*.tmp"))) + + def test_rotation_threshold(self): + active = self.write_file("maafw.log", b"before rotation\n", size=127 * MIB) + self.maintain() + self.assertFalse(list(self.root.glob("maafw.bak.*"))) + + with active.open("r+b") as stream: + stream.truncate(128 * MIB) + self.maintain() + + archives = list(self.root.glob("maafw.bak.*.log.gz")) + self.assertEqual(len(archives), 1) + size = 0 + with gzip.open(archives[0], "rb") as stream: + self.assertEqual(stream.read(16), b"before rotation\n") + size += 16 + while chunk := stream.read(MIB): + size += len(chunk) + self.assertEqual(size, 128 * MIB) + self.assertLess(active.stat().st_size, MIB) + + @unittest.skipIf(os.name == "nt", "requires POSIX file-size limits") + def test_compression_write_failure_keeps_source(self): + content = os.urandom(128 * 1024) + original = self.write_file("maafw.bak.write-failure.log", content) + + self.maintain( + "import resource, signal; " + "signal.signal(signal.SIGXFSZ, signal.SIG_IGN); " + "resource.setrlimit(resource.RLIMIT_FSIZE, (4096, 4096)); " + ) + + self.assertEqual(original.read_bytes(), content) + self.assertFalse(original.with_suffix(".log.gz").exists()) + self.assertFalse(list(self.root.glob("*.tmp"))) + + @unittest.skipIf(os.name == "nt", "requires POSIX file-size limits") + def test_rotation_write_failure_keeps_active_log(self): + active = self.write_file("maafw.log", b"must survive\n", size=128 * MIB) + + self.maintain( + "import resource, signal; " + "signal.signal(signal.SIGXFSZ, signal.SIG_IGN); " + "resource.setrlimit(resource.RLIMIT_FSIZE, (4096, 4096)); " + ) + + self.assertEqual(active.stat().st_size, 128 * MIB) + with active.open("rb") as stream: + self.assertEqual(stream.read(13), b"must survive\n") + self.assertFalse(list(self.root.glob("maafw.bak.*"))) + + @unittest.skipIf(os.name == "nt", "symlink creation requires privileges on Windows") + def test_symlinks_are_not_compressed_or_cleaned(self): + target = Path(self.directory.name) / "outside.log" + target.write_bytes(b"outside evidence") + modified = time.time() - 10 * DAY + os.utime(target, (modified, modified)) + link = self.root / "maafw.bak.symlink.log" + link.symlink_to(target) + (self.root / "vision").symlink_to(target.parent, target_is_directory=True) + + self.maintain() + + self.assertTrue(link.is_symlink()) + self.assertEqual(target.read_bytes(), b"outside evidence") + + +if __name__ == "__main__": + unittest.main() From 0c6235342b5ce927f0ebc67af5bbc30c4abc94a0 Mon Sep 17 00:00:00 2001 From: Marin Wang Date: Fri, 13 Nov 2026 17:55:26 +0800 Subject: [PATCH 2/4] docs: keep log upload guidance concise --- docs/en_us/5.1-Troubleshooting.md | 3 --- .../5.1-\351\227\256\351\242\230\345\217\215\351\246\210.md" | 3 --- 2 files changed, 6 deletions(-) diff --git a/docs/en_us/5.1-Troubleshooting.md b/docs/en_us/5.1-Troubleshooting.md index a7b4e68f45..40099942a3 100644 --- a/docs/en_us/5.1-Troubleshooting.md +++ b/docs/en_us/5.1-Troubleshooting.md @@ -25,9 +25,6 @@ Logs are critical for troubleshooting. Please provide the complete log file. By default, the log file is located at: `/debug/maafw.log` -Rotation is checked during initialization and flushes such as task startup, at a threshold of 128 MiB (not a continuous hard limit). Completed `maafw.bak..log` backups are compressed in the background to `.log.gz` using gzip level 1; the current `maafw.log` remains plain text. Initialization, rotation, and task completion trigger background maintenance; routine checks run at most once every five minutes. -Historical logs and `on_error/` screenshots are retained for seven days, and `vision/` drawings for three days. When these historical artifacts exceed a combined 1 GiB budget, vision drawings are removed first, followed by historical logs and finally error screenshots, oldest first within each category. The current log is excluded from this budget, and images are not recompressed. Save diagnostic files elsewhere if you need to keep them longer. - ### Before Uploading 1. Open the log file and scroll to the end to verify the timestamp matches when the issue occurred diff --git "a/docs/zh_cn/5.1-\351\227\256\351\242\230\345\217\215\351\246\210.md" "b/docs/zh_cn/5.1-\351\227\256\351\242\230\345\217\215\351\246\210.md" index 25afb76192..85198647ef 100644 --- "a/docs/zh_cn/5.1-\351\227\256\351\242\230\345\217\215\351\246\210.md" +++ "b/docs/zh_cn/5.1-\351\227\256\351\242\230\345\217\215\351\246\210.md" @@ -25,9 +25,6 @@ 若无特殊设置,日志文件位于:`<工作目录>/debug/maafw.log` -日志在初始化、任务开始等 flush 时机检查轮转,阈值为 128 MiB(并非实时硬上限)。已完成轮转的 `maafw.bak.<时间>.log` 会在后台使用 gzip level 1 压缩为 `.log.gz`,当前 `maafw.log` 保持文本格式。初始化、轮转及任务结束会触发后台维护;常规检查最多每 5 分钟一次。 -历史日志和 `on_error/` 截图保留 7 天,`vision/` 调试图片保留 3 天;这些历史产物合计超过 1 GiB 时,优先删除 `vision/` 图片,再删除历史日志,最后删除错误截图,各类内部按从旧到新的顺序清理。当前日志不计入此预算,图片不会重复压缩。请及时保存需要长期保留的诊断文件。 - ### 上传前请确认 1. 打开日志文件,翻到末尾检查时间戳是否为问题发生时间 From 0abbf8fc26b805c63aeebb8b3824b5de2b178b5b Mon Sep 17 00:00:00 2001 From: Marin Wang Date: Fri, 13 Nov 2026 18:18:30 +0800 Subject: [PATCH 3/4] test: cover interrupted log maintenance with gzip level 6 --- source/MaaUtils | 2 +- test/python/test_log_maintenance.py | 25 ++++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/source/MaaUtils b/source/MaaUtils index 9d7e8b1dd0..8fe8cbe6dd 160000 --- a/source/MaaUtils +++ b/source/MaaUtils @@ -1 +1 @@ -Subproject commit 9d7e8b1dd096fbc6c595aa43053bc1d75dc23401 +Subproject commit 8fe8cbe6ddec3701b60278c8c64a8097c1c8ae20 diff --git a/test/python/test_log_maintenance.py b/test/python/test_log_maintenance.py index 4bf48ae354..7a43b19409 100644 --- a/test/python/test_log_maintenance.py +++ b/test/python/test_log_maintenance.py @@ -32,15 +32,16 @@ def write_file(self, name, content=b"diagnostic evidence\n", age_days=0, size=No os.utime(path, (modified, modified)) return path - def maintain(self, prelude=""): - # Process exit joins the native maintenance worker, avoiding sleeps and - # changes to the parent test process's global logging configuration. + def maintain(self, prelude="", wait=True): + # Disabling logging waits for maintenance before the child exits. Isolate + # the parent test process's global logging configuration. + finish = "; assert Tasker.set_log_dir('')" if wait else "" result = subprocess.run( [ sys.executable, "-c", prelude + "import sys; from maa.tasker import Tasker; " - "assert Tasker.set_log_dir(sys.argv[1])", + "assert Tasker.set_log_dir(sys.argv[1])" + finish, str(self.root), ], capture_output=True, @@ -61,7 +62,7 @@ def test_gzip_round_trip_preserves_age_and_active_log(self): archive = backup.with_suffix(".log.gz") with gzip.open(archive, "rb") as stream: self.assertEqual(stream.read(), content) - self.assertEqual(archive.read_bytes()[8], 4) # gzip's fastest-compression flag + self.assertLess(archive.stat().st_size, len(content)) self.assertAlmostEqual(archive.stat().st_mtime, modified, delta=2) self.assertFalse(backup.exists()) self.assertTrue(active.read_bytes().startswith(b"active log\n")) @@ -136,6 +137,20 @@ def test_rotation_threshold(self): self.assertEqual(size, 128 * MIB) self.assertLess(active.stat().st_size, MIB) + def test_exit_during_maintenance_can_resume(self): + backup = self.write_file("maafw.bak.interrupted.log", size=128 * MIB) + archive = backup.with_suffix(".log.gz") + + self.maintain(wait=False) + + self.assertTrue(backup.exists() or archive.is_file()) + self.maintain() + size = 0 + with gzip.open(archive, "rb") as stream: + while chunk := stream.read(MIB): + size += len(chunk) + self.assertEqual(size, 128 * MIB) + @unittest.skipIf(os.name == "nt", "requires POSIX file-size limits") def test_compression_write_failure_keeps_source(self): content = os.urandom(128 * 1024) From 373275ba98eff35a780f4858ec4db425c35e6f6e Mon Sep 17 00:00:00 2001 From: Marin Wang Date: Fri, 13 Nov 2026 18:42:32 +0800 Subject: [PATCH 4/4] chore: pin merged MaaUtils log maintenance --- source/MaaUtils | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/source/MaaUtils b/source/MaaUtils index 8fe8cbe6dd..af3fce7298 160000 --- a/source/MaaUtils +++ b/source/MaaUtils @@ -1 +1 @@ -Subproject commit 8fe8cbe6ddec3701b60278c8c64a8097c1c8ae20 +Subproject commit af3fce72982c009ad55c02f47b41e13082738eb9