Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/kimi_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,19 @@
from kimi_cli.constant import VERSION
from kimi_cli.llm import augment_provider_with_env_vars, create_llm, model_display_name
from kimi_cli.session import Session
from kimi_cli.share import get_share_dir
from kimi_cli.soul import RunCancelled, run_soul
from kimi_cli.soul.agent import Runtime, load_agent
from kimi_cli.soul.context import Context
from kimi_cli.soul.kimisoul import KimiSoul
from kimi_cli.soul.toolset import KimiToolset
from kimi_cli.utils.aioqueue import QueueShutDown
from kimi_cli.utils.envvar import get_env_bool
from kimi_cli.utils.logging import logger, open_original_stderr, redirect_stderr_to_logger
from kimi_cli.utils.logging import (
get_log_file_path,
logger,
open_original_stderr,
redirect_stderr_to_logger,
)
from kimi_cli.utils.path import shorten_home
from kimi_cli.wire import Wire, WireUISide
from kimi_cli.wire.types import ApprovalRequest, ApprovalResponse, ContentPart, WireMessage
Expand Down Expand Up @@ -59,7 +63,7 @@ def enable_logging(debug: bool = False, *, redirect_stderr: bool = True) -> None
if debug:
logger.enable("kosong")
logger.add(
get_share_dir() / "logs" / "kimi.log",
get_log_file_path(),
# FIXME: configure level for different modules
level="TRACE" if debug else "INFO",
format=(
Expand Down
4 changes: 2 additions & 2 deletions src/kimi_cli/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -864,9 +864,9 @@ async def _pick_session() -> str:
# In debug mode, show full traceback for quick diagnosis.
_emit_fatal_error(traceback.format_exc())
else:
from kimi_cli.share import get_share_dir
from kimi_cli.utils.logging import get_log_file_path

log_path = get_share_dir() / "logs" / "kimi.log"
log_path = get_log_file_path()
# In non-debug mode, print a concise error and point users to logs.
_emit_fatal_error(
f"{exc}\n"
Expand Down
14 changes: 14 additions & 0 deletions src/kimi_cli/utils/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,23 @@
import sys
import threading
from collections.abc import Iterator
from pathlib import Path
from typing import IO

from kimi_cli import logger
from kimi_cli.share import get_share_dir


def get_log_file_path() -> Path:
"""Return a log path that is safe for concurrent Windows processes.

Windows does not allow Loguru to rename a file for rotation while another
process has it open. Other platforms retain the existing shared log.
"""
logs_dir = get_share_dir() / "logs"
if sys.platform == "win32":
return logs_dir / f"kimi.{os.getpid()}.log"
return logs_dir / "kimi.log"
Comment on lines +24 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Old Windows log files are never deleted, causing disk usage to grow forever

On Windows each run writes to a new per-process log file (kimi.{os.getpid()}.log at src/kimi_cli/utils/logging.py:25), but Loguru's retention="10 days" only removes rotated files that match the current process's own filename, so log files from previously exited processes are never cleaned up.
Impact: On Windows the logs directory grows without bound across CLI invocations, since the retention policy no longer bounds the total set of log files.

Why retention no longer bounds the log directory on Windows

Loguru's retention derives its cleanup glob from the sink's own filename. For a sink named kimi.<pid>.log, the retention pattern only matches files beginning with kimi.<pid> — it cannot see kimi.<other-pid>.log files created by other/previous processes. Before this change all processes shared kimi.log, so retention="10 days" (configured at src/kimi_cli/app.py:74) cleaned all rotated logs. Now every distinct PID leaves behind its own files that no process will ever purge. The export path (src/kimi_cli/cli/export.py:130-168) only reads logs for bundling; it does not delete them, so there is no other mechanism bounding disk usage.

Prompt for agents
On Windows, get_log_file_path() in src/kimi_cli/utils/logging.py returns a per-PID filename (kimi.<pid>.log). Loguru's retention="10 days" (set in enable_logging in src/kimi_cli/app.py) only cleans files whose names match the current sink's filename stem, so it will never remove kimi.<pid>.log files left behind by other/previous processes. This means the logs directory grows unbounded on Windows. Consider adding an explicit cleanup pass that removes old kimi.*.log files across all PIDs (e.g. deleting files older than the retention window when logging is initialized), or otherwise ensure stale per-process log files are eventually purged.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.



class StderrRedirector:
Expand Down
8 changes: 5 additions & 3 deletions tests/e2e/test_cli_error_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
import re
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -35,17 +36,18 @@ def _run_kimi(args: list[str], *, share_dir: Path) -> subprocess.CompletedProces
def _normalize_cli_error_output(text: str) -> str:
"""Normalize Rich/Click error boxes across platforms for snapshot tests."""
text = text.replace("\r\n", "\n")
text = re.sub(r"kimi\.\d+\.log", "kimi.log", text)
lines: list[str] = []
in_box = False
for line in text.splitlines():
if line.startswith(("╭", "┌")) and "Error" in line:
if line.startswith(("╭", "┌", "+-")) and "Error" in line:
in_box = True
lines.append("Error:")
continue
if in_box and line.startswith(("╰", "└")):
if in_box and line.startswith(("╰", "└", "+-")):
in_box = False
continue
if in_box and line.startswith(("│", "┃")) and line.endswith(("│", "┃")):
if in_box and line.startswith(("│", "┃", "|")) and line.endswith(("│", "┃", "|")):
inner = line[1:-1].strip()
if inner:
lines.append(inner)
Expand Down
85 changes: 85 additions & 0 deletions tests/utils/test_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
from __future__ import annotations

import os
import subprocess
import sys
import time
from pathlib import Path

import pytest

from kimi_cli.utils import logging as logging_utils


def test_get_log_file_path_keeps_shared_log_off_windows(monkeypatch, tmp_path: Path):
monkeypatch.setenv("KIMI_SHARE_DIR", str(tmp_path))
monkeypatch.setattr(logging_utils.sys, "platform", "linux")

assert logging_utils.get_log_file_path() == tmp_path / "logs" / "kimi.log"


def test_get_log_file_path_uses_process_log_on_windows(monkeypatch, tmp_path: Path):
monkeypatch.setenv("KIMI_SHARE_DIR", str(tmp_path))
monkeypatch.setattr(logging_utils.sys, "platform", "win32")
monkeypatch.setattr(logging_utils.os, "getpid", lambda: 12345)

assert logging_utils.get_log_file_path() == tmp_path / "logs" / "kimi.12345.log"


@pytest.mark.skipif(sys.platform != "win32", reason="Windows file locking regression")
def test_windows_processes_can_rotate_logs_concurrently(tmp_path: Path):
"""Exercise Loguru's real Windows file handles in separate processes."""
ready = tmp_path / "ready"
release = tmp_path / "release"
env = os.environ.copy()
env["KIMI_SHARE_DIR"] = str(tmp_path)

holder_code = """
import os, time
from loguru import logger
from kimi_cli.utils.logging import get_log_file_path
logger.remove()
logger.add(get_log_file_path(), rotation='1 B')
logger.info('holder')
open(os.environ['READY'], 'w').close()
while not os.path.exists(os.environ['RELEASE']):
time.sleep(0.01)
"""
env["READY"] = str(ready)
env["RELEASE"] = str(release)
holder = subprocess.Popen(
[sys.executable, "-c", holder_code],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
try:
for _ in range(500):
if ready.exists():
break
if holder.poll() is not None:
break
time.sleep(0.01)
assert ready.exists(), holder.communicate(timeout=1)[1]

writer_code = """
from loguru import logger
from kimi_cli.utils.logging import get_log_file_path
logger.remove()
logger.add(get_log_file_path(), rotation='1 B')
logger.info('writer')
"""
writer = subprocess.run(
[sys.executable, "-c", writer_code],
env=env,
capture_output=True,
text=True,
timeout=10,
)
assert writer.returncode == 0
assert "PermissionError" not in writer.stderr
assert len(list((tmp_path / "logs").glob("kimi.*.log"))) >= 2
finally:
release.touch()
holder.communicate(timeout=10)
Loading