Skip to content
Merged
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
2 changes: 2 additions & 0 deletions .trellis/spec/backend/error-handling.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ subprocess.run(f"nssm.exe start {service_name}", shell=True)

`nssm.exe` 找不到或返回非零退出码时,CLI 命令失败,并把 stderr 或 stdout 中的系统错误摘要展示给用户。

捕获 `nssm.exe` stdout/stderr 时必须显式指定 `encoding="utf-8"` 和 `errors="replace"`,不得依赖 Windows 系统默认编码。外部输出中出现无法按 UTF-8 解码的字节时,保留替换字符并继续走正常的返回码与错误摘要处理。

## 服务状态错误

- `sing start <name>` 在服务已运行时失败,并提示使用 `sing restart <name>`。
Expand Down
14 changes: 12 additions & 2 deletions .trellis/spec/backend/quality-guidelines.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,21 @@ nssm.exe remove sing-box confirm
- `sing restart <name>` 必须先停止服务,停止成功后再写入 NSSM 参数并启动服务。
- `nssm.exe` 必须从 `PATH` 解析;项目不下载、不内置 NSSM。
- 外部命令必须用参数列表调用,不通过 shell 字符串执行。
- 捕获外部命令输出时必须显式使用 `encoding="utf-8"` 和 `errors="replace"`,避免 Windows locale 默认编码导致 reader thread 抛出 `UnicodeDecodeError`。

### 4. Validation & Error Matrix

| 条件 | 行为 |
|------|------|
| `nssm.exe` 不在 `PATH` | 命令失败并提示安装 NSSM 且让 `nssm.exe` 可从 `PATH` 访问 |
| `nssm.exe` 返回非零退出码 | 命令失败并暴露 stderr 或 stdout 摘要 |
| `nssm.exe` 输出包含当前系统编码无法解码的字节 | 命令不因解码崩溃,输出中的非法字节以替换字符呈现 |
| `nssm.exe status sing-box` 输出 `SERVICE_RUNNING` | `service_is_running()` 返回 `True` |
| `sing start <name>` 发现服务已运行 | 命令失败并提示使用 `sing restart <name>` |

### 5. Good/Base/Bad Cases

- Good: `subprocess.run([nssm_path, "set", "sing-box", "AppParameters", 'run -c "C:/profiles/home"'], ...)`
- Good: `subprocess.run([nssm_path, "set", "sing-box", "AppParameters", 'run -c "C:/profiles/home"'], capture_output=True, check=False, text=True, encoding="utf-8", errors="replace")`
- Base: profile 路径包含空格时,`AppParameters` 仍作为单个参数传给 NSSM。
- Bad: `subprocess.run(f"nssm.exe set sing-box AppParameters run -c {profile_path}", shell=True)`

Expand All @@ -85,6 +87,7 @@ nssm.exe remove sing-box confirm
- 配置服务测试断言 `Application` 与 `AppParameters` 分别写入。
- NSSM 失败测试断言非零退出码转换为 CLI 错误。
- NSSM 缺失测试断言错误信息说明 `nssm.exe` 不在 `PATH`。
- 默认 runner 测试断言非法 UTF-8 输出不会触发 `UnicodeDecodeError`,并以替换字符保留在 stdout/stderr 中。

### 7. Wrong vs Correct

Expand All @@ -97,7 +100,14 @@ subprocess.run(f"nssm.exe start {SERVICE_NAME}", shell=True)
#### Correct

```python
subprocess.run([nssm_path, "start", SERVICE_NAME], capture_output=True, check=False, text=True)
subprocess.run(
[nssm_path, "start", SERVICE_NAME],
capture_output=True,
check=False,
text=True,
encoding="utf-8",
errors="replace",
)
```

## Ruff 工具链契约
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Fix Windows Subprocess Output Decoding

## Goal

Fix `sing start <name>` and `sing stop` on Windows so NSSM subprocess output does not crash background reader threads when the system locale is GBK and the child process emits bytes that are not valid GBK.

## Requirements

- Decode NSSM subprocess stdout and stderr with a deterministic encoding instead of the Windows locale default.
- Keep NSSM failures explicit: non-zero exit codes must still raise `ExternalCommandError` with stderr or stdout content.
- Do not introduce shell command strings, retries, mock success paths, or silent command degradation.
- Cover the default runner behavior with tests.

## Acceptance Criteria

- [ ] `default_runner()` calls `subprocess.run()` with explicit `encoding`.
- [ ] `default_runner()` handles undecodable bytes without raising `UnicodeDecodeError` from subprocess reader threads.
- [ ] Existing NSSM command construction and error propagation tests still pass.
- [ ] Ruff, ty, and pytest pass for the changed code.

## Definition of Done

- Tests added or updated for the decoding behavior.
- Lint, type check, and tests are green.
- No documentation changes are needed unless command behavior changes.

## Technical Approach

Use explicit UTF-8 decoding for captured subprocess output and configure decode errors to replace invalid byte sequences. NSSM output is only used for status checks and human-readable error summaries, so replacement exposes problematic bytes without crashing the CLI.

## Decision (ADR-lite)

Context: `subprocess.run(..., text=True)` uses the platform default encoding when no `encoding` is provided. On Windows with a GBK locale, NSSM or service output can include bytes that are not valid GBK, causing `UnicodeDecodeError` in subprocess reader threads.

Decision: Set `encoding="utf-8"` and `errors="replace"` in the shared `default_runner()`.

Consequences: Captured output remains text for existing service logic, invalid byte sequences are visible as replacement characters, and command failures continue to surface through the existing error path.

## Out of Scope

- Changing NSSM command arguments or service semantics.
- Adding retries or service state recovery.
- Adding a logging framework.

## Technical Notes

- Relevant files: `src/sing_cli/service.py`, `tests/test_service.py`.
- Relevant specs: `.trellis/spec/backend/error-handling.md`, `.trellis/spec/backend/quality-guidelines.md`, `.trellis/spec/backend/logging-guidelines.md`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"id": "fix-windows-subprocess-output-decoding",
"name": "fix-windows-subprocess-output-decoding",
"title": "Fix Windows subprocess output decoding",
"description": "",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "pixelcola",
"assignee": "pixelcola",
"createdAt": "2026-05-12",
"completedAt": "2026-05-12",
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
5 changes: 3 additions & 2 deletions .trellis/workspace/pixelcola/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 3
- **Total Sessions**: 4
- **Last Active**: 2026-05-12
<!-- @@@/auto:current-status -->

Expand All @@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~106 | Active |
| `journal-1.md` | ~139 | Active |
<!-- @@@/auto:active-documents -->

---
Expand All @@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 4 | 2026-05-12 | Fix Windows subprocess output decoding | `e90a676` | `fix/windows-subprocess-output-decoding` |
| 3 | 2026-05-12 | Use NSSM for Windows service | `72c1b0d` | `fix/use-nssm-service` |
| 2 | 2026-05-12 | Windows sing-box CLI | `2f18462` | `feature/windows-sing-box-cli` |
| 1 | 2026-05-12 | Bootstrap Guidelines | `ced349d` | `main` |
Expand Down
33 changes: 33 additions & 0 deletions .trellis/workspace/pixelcola/journal-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,36 @@ Replaced direct sc.exe service management with NSSM commands, updated service te
### Next Steps

- None - task complete


## Session 4: Fix Windows subprocess output decoding

**Date**: 2026-05-12
**Task**: Fix Windows subprocess output decoding
**Branch**: `fix/windows-subprocess-output-decoding`

### Summary

Created PR #4 for the Windows subprocess decoding fix. Updated NSSM subprocess output capture to use explicit UTF-8 replacement handling, added regression coverage, and recorded the backend decoding contract.

### Main Changes

(Add details)

### Git Commits

| Hash | Message |
|------|---------|
| `e90a676` | (see git log) |

### Testing

- [OK] (Add test results)

### Status

[OK] **Completed**

### Next Steps

- None - task complete
2 changes: 1 addition & 1 deletion src/sing_cli/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@


def default_runner(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(command, capture_output=True, check=False, text=True)
return subprocess.run(command, capture_output=True, check=False, text=True, encoding="utf-8", errors="replace")


def ensure_windows() -> None:
Expand Down
10 changes: 10 additions & 0 deletions tests/test_service.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import subprocess
import sys
from pathlib import Path

import pytest
Expand All @@ -21,6 +22,15 @@ def enable_windows(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(service.shutil, "which", lambda executable: f"C:/tools/{executable}")


def test_default_runner_replaces_invalid_utf8_output() -> None:
result = service.default_runner(
[sys.executable, "-c", "import sys; sys.stdout.buffer.write(b'valid' + bytes([0x82]) + b'output')"]
)

assert result.returncode == 0
assert result.stdout == "valid\ufffdoutput"


def test_configure_service_uses_argument_list(monkeypatch: pytest.MonkeyPatch) -> None:
enable_windows(monkeypatch)
commands: list[list[str]] = []
Expand Down