Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
52e6976
Feat/log command improvements: add support for line count, follow mod…
kooksee May 31, 2026
2a7c413
Feat/log command improvements: remove grep filtering support and upda…
kooksee May 31, 2026
a571a52
Feat: add GitHub Actions workflow for ziginit release process
kooksee May 31, 2026
aca5d93
Feat: 修复 sleepNs 函数中的类型转换问题
kooksee May 31, 2026
0d3a726
Feat/log: 保留空行并增强行计数参数的错误处理
kooksee May 31, 2026
07d0bac
Feat/log: 实现 follow 模式的跨 chunk 行拼接,增强日志输出完整性
kooksee May 31, 2026
e8af566
Feat/log: 添加路径长度检查,防止越界写入
kooksee May 31, 2026
ff42f6f
Feat/log: 优化路径构建,增加路径长度检查并改进文件跟踪逻辑
kooksee May 31, 2026
37bc55f
Feat/log: 增强 sockaddrUn 和 sendCommand 函数的路径长度检查,防止缓冲区溢出
kooksee May 31, 2026
856374c
Feat/log: 增强命令处理和响应格式,确保 CLI 命令失败时正确退出并记录错误信息
kooksee Jun 1, 2026
91d88f1
Feat/log: 增强 CLI 错误处理,确保命令失败时返回详细错误信息并正确退出
kooksee Jun 1, 2026
990d9a7
更新 README.md,修正服务日志托管描述,调整模块行数信息,优化命令启动说明
kooksee Jun 1, 2026
9a453aa
Feat/log: 添加日志轮转功能,支持备份文件管理和流式复制
kooksee Jun 1, 2026
6852115
Feat/log: 将多个函数参数类型从 [*:0]const u8 修改为 c.CStr,以增强 C 字符串处理的安全性和一致性
kooksee Jun 2, 2026
67e22f5
Feat/log: 添加 gzip 可用性检查,确保日志轮转时备份安全有效
kooksee Jun 2, 2026
2920e6a
Feat/log: 优化日志输出处理,确保数据完整性并提升性能
kooksee Jun 3, 2026
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
4 changes: 4 additions & 0 deletions tools/ziginit/config.zig
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ pub const MAX_LOG_TAIL_SIZE: usize = 4 * 1024 * 1024;
pub const LOG_MAX_SIZE: usize = 10 * 1024 * 1024;
// ZIGINIT_LOG_MAX_KB 环境变量允许的最大值
pub const LOG_MAX_SIZE_UPPER: usize = 100 * 1024 * 1024;
// `log -n` 默认显示行数
pub const DEFAULT_LOG_LINES: u32 = 100;
// `log -f` follow 模式轮询间隔
pub const LOG_FOLLOW_POLL_NS: i64 = 200_000_000; // 200ms

// ── 默认路径 ──

Expand Down
131 changes: 131 additions & 0 deletions tools/ziginit/docs/log-improvements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Log 子命令改进

## 1. 动机

原有 `log` 命令通过 Unix Socket 向 daemon 请求日志数据,daemon 读取日志文件后流式写回客户端 socket。这种架构存在以下限制:

- **无法 follow**:socket 是请求-响应模型,无法持续推送新数据
- **增加延迟**:CLI → daemon → 读文件 → 回传 socket → CLI,多一跳中转
- **daemon 耦合**:daemon 停机时无法查看日志
- **按字节截断**:尾部读 64KB 字节,不保证从完整行开始

## 2. 新架构

CLI 直接读取日志文件,绕过 daemon socket。

```mermaid
graph LR
subgraph "旧架构"
A1[CLI] -->|socket| B1[daemon]
B1 -->|read| C1[log files]
B1 -->|stream| A1
end

subgraph "新架构"
A2[CLI] -->|direct read| C2[log files]
end

style A1 fill:#fee2e2,stroke:#dc2626
style A2 fill:#dcfce7,stroke:#16a34a
```

日志文件位于 `{workdir}/{service}.log` 和 `{workdir}/daemon.log`,由 daemon 进程通过 `O_APPEND` 模式写入。CLI 直读不会与 daemon 写入冲突。

## 3. CLI 用法

```
ziginit log <name> # 显示最近 100 行
ziginit log -n <N> <name> # 显示最近 N 行
ziginit log -f <name> # 实时跟踪(类似 tail -f)
ziginit log daemon # 查看 daemon 日志
ziginit log --all # 所有服务 + daemon 日志(带 [name] 前缀)
ziginit log -f --all # follow 所有服务
```

过滤日志使用 Unix 管道:`ziginit log -f svc | grep error`

### 参数说明

| 参数 | 默认值 | 说明 |
| -------- | ------ | ------------------------------------- |
| `-n <N>` | 100 | 显示尾部 N 行 |
| `-f` | off | follow 模式,持续输出新行直到 Ctrl-C |
| `--all` | off | 显示所有服务 + daemon 日志 |
| `daemon` | — | 伪服务名,指向 `{workdir}/daemon.log` |

### `--all` 输出格式

每行加 `[name] ` 前缀(去掉了旧版的读取时间戳,因为它不是写入时间,会产生误导):

```
[daemon] daemon started, 2 services
[svc-a] svc-a-out
[svc-a] svc-a-err
[svc-b] listening on :8080
```

## 4. 实现细节

### 核心函数(journal.zig)

| 函数 | 职责 |
| -------------------------------- | ---------------------------------------------------------------------- |
| `tailLastLines(path, n, prefix)` | 从文件尾部读取,反向扫描定位最后 N 行,应用 prefix 后输出到 stdout |
| `followFiles(targets)` | 轮询文件变化(200ms 间隔),输出新增行,检测 `sigpending` 实现优雅退出 |
| `outputLines(data, prefix)` | 逐行处理:前缀添加 + stdout 写入(过滤交给外部 `grep` 管道) |

### FollowTarget 结构

```zig
pub const FollowTarget = struct {
path_buf: [MAX_PATH + 1]u8, // 自持路径数据,避免悬空指针
path_len: usize,
offset: usize, // 当前读取位置(= tailLastLines 返回的文件大小)
prefix: [MAX_NAME + 4]u8, // "[name] " 前缀
prefix_len: usize,
};
```

> **设计决策**:`FollowTarget` 拷贝路径到内部缓冲区而非存储外部指针。原因是 `ServicePaths` 是栈上临时值,循环迭代结束后失效。存裸指针会导致 Release 模式悬空指针崩溃(与 `ConfigDtoBuild` 同类问题)。

### Follow 模式实现

```mermaid
graph TD
A[tailLastLines 初始输出] --> B[blockTermInt 阻塞信号]
B --> C{sleep 200ms}
C --> D{sigpending?}
D -->|是| E[恢复信号 & 退出]
D -->|否| F[遍历 targets]
F --> G{fstat: 文件增长?}
G -->|否| C
G -->|文件缩小| H[offset = 0 重置]
H --> C
G -->|增长| I[seekTo + read 新数据]
I --> J[outputLines 过滤输出]
J --> K[更新 offset]
K --> C
```

- 使用 `fstat` 检测文件大小变化,而非 `poll()`(`poll` 对普通文件总是立即返回 `POLLIN`)
- 文件大小减小时判定为日志轮转,重置 offset 到 0 重新读取
- 信号检查使用 `sigpending`,不依赖信号 handler 全局状态

## 5. 删除的代码

| 位置 | 删除内容 |
| -------------- | -------------------------------------------------------------------- |
| `journal.zig` | `readServiceLogTail`、`readServiceLogTailPrefixed`(旧 socket 模式) |
| `server.zig` | `ACTION_LOG` 流式转发逻辑(serverLoop + handleCommand) |
| `protocol.zig` | `ACTION_LOG` 常量、`sendCommand` 中的 log 流式读取 |

## 6. 配置常量(config.zig)

```zig
pub const DEFAULT_LOG_LINES: u32 = 100; // -n 默认值
pub const LOG_FOLLOW_POLL_NS: i64 = 200_000_000; // follow 轮询间隔 200ms
```

沿用现有环境变量:
- `ZIGINIT_LOG_TAIL_KB`:控制 tail 读取的最大字节数(默认 64KB,上限 4MB)
- `ZIGINIT_LOG_MAX_KB`:控制日志轮转阈值(默认 10MB)
160 changes: 102 additions & 58 deletions tools/ziginit/journal.zig
Original file line number Diff line number Diff line change
Expand Up @@ -51,91 +51,135 @@ pub fn formatTimestamp(buf: []u8) usize {

// ─── Log Tail Reading ────────────────────────────────────────────────────────

/// 读取指定服务的日志文件尾部,写入 out_fd。
/// 日志文件是服务进程直接 append 的 stdout/stderr 混合输出。
pub fn readServiceLogTail(log_path: [*:0]const u8, out_fd: c_int) bool {
/// 逐行输出 data 中的内容,可选 grep 过滤和行前缀。
fn outputLines(data: []const u8, prefix: []const u8) void {
var pos: usize = 0;
while (pos < data.len) {
var end = pos;
while (end < data.len and data[end] != '\n') end += 1;
const line = data[pos..end];
if (line.len > 0) {
if (prefix.len > 0) c.writeOnce(1, prefix);
c.writeOnce(1, line);
c.writeOnce(1, "\n");
}
Comment thread
kooksee marked this conversation as resolved.
Outdated
pos = end + 1;
}
}

/// CLI 直读日志文件尾部,显示最后 n 行(可选 grep 过滤和行前缀)。
/// 返回当前文件大小供 follow 模式使用,失败返回 0。
pub fn tailLastLines(log_path: [*:0]const u8, n: u32, prefix: []const u8) usize {
const fd = c.openRead(log_path) orelse {
c.writeOnce(out_fd, "no log available\n");
return false;
helpers.logErr("no log: {s}\n", .{std.mem.sliceTo(log_path, 0)});
return 0;
};
defer c.closeFd(fd);

const file_size = c.fileSize(fd) orelse return false;
if (file_size <= 0) return false;
const file_size_off = c.fileSize(fd) orelse return 0;
if (file_size_off <= 0) return 0;
const file_size: usize = @intCast(file_size_off);
Comment thread
kooksee marked this conversation as resolved.

const ts: usize = logTailSize();
const fs: usize = @intCast(file_size);
const read_size = @min(ts, fs);
const tail_start: c.off_t = @intCast(fs - read_size);
const max_read = logTailSize();
const read_size = @min(file_size, max_read);
const tail_start: c.off_t = @intCast(file_size - read_size);
c.seekTo(fd, tail_start);

const data = std.heap.c_allocator.alloc(u8, read_size) catch return false;
const data = std.heap.c_allocator.alloc(u8, read_size) catch return 0;
defer std.heap.c_allocator.free(data);

const total = c.readLoop(fd, data.ptr, read_size);
if (total == 0) return false;
if (total == 0) return file_size;

// Skip first partial line if we started mid-file
var start: usize = 0;
var content_start: usize = 0;
if (tail_start > 0) {
while (start < total and data[start] != '\n') start += 1;
if (start < total) start += 1;
while (content_start < total and data[content_start] != '\n') content_start += 1;
if (content_start < total) content_start += 1;
}

// Output all lines (no filtering — log file is per-service)
if (start < total) {
c.writeOnce(out_fd, data[start..total]);
// Scan backwards to find start of last N lines
var pos = total;
var lines_found: u32 = 0;
if (pos > content_start and data[pos - 1] == '\n') pos -= 1; // skip trailing newline
while (pos > content_start) {
pos -= 1;
if (data[pos] == '\n') {
lines_found += 1;
if (lines_found >= n) {
pos += 1;
break;
}
}
}
return true;
if (pos <= content_start) pos = content_start;

outputLines(data[pos..total], prefix);
return file_size;
}

/// 读取日志尾部,每行加 [name timestamp] 前缀后写入 out_fd。
/// 用于 `log --all` 合并多个服务的日志输出。
pub fn readServiceLogTailPrefixed(log_path: [*:0]const u8, out_fd: c_int, name: []const u8) bool {
const fd = c.openRead(log_path) orelse return false;
defer c.closeFd(fd);
/// follow 模式跟踪目标(自持路径数据,避免悬空指针)
pub const FollowTarget = struct {
path_buf: [config.MAX_PATH + 1]u8,
path_len: usize,
offset: usize,
prefix: [config.MAX_NAME + 4]u8,
prefix_len: usize,

const file_size = c.fileSize(fd) orelse return false;
if (file_size <= 0) return false;
pub fn pathZ(self: *const FollowTarget) [*:0]const u8 {
return @ptrCast(self.path_buf[0..self.path_len]);
}
Comment thread
kooksee marked this conversation as resolved.
Outdated
pub fn prefixSlice(self: *const FollowTarget) []const u8 {
return self.prefix[0..self.prefix_len];
}
/// 从 null-terminated 源拷贝路径到内部缓冲区。
pub fn setPath(self: *FollowTarget, src: [*:0]const u8) void {
const s = std.mem.sliceTo(src, 0);
@memcpy(self.path_buf[0..s.len], s);
self.path_buf[s.len] = 0;
self.path_len = s.len;
}
};

const ts: usize = logTailSize();
const fs: usize = @intCast(file_size);
const read_size = @min(ts, fs);
const tail_start: c.off_t = @intCast(fs - read_size);
c.seekTo(fd, tail_start);
/// follow 模式:轮询文件变化,输出新增行直到收到 SIGTERM/SIGINT。
/// 调用前应先 tailLastLines 完成初始输出。
pub fn followFiles(targets: []FollowTarget) void {
// 阻塞 SIGTERM/SIGINT,用 sigpending 同步检查
const old_mask = c.blockTermInt();
defer c.restoreSigmask(&old_mask);

const data = std.heap.c_allocator.alloc(u8, read_size) catch return false;
defer std.heap.c_allocator.free(data);
var buf: [config.PIPE_BUF_SIZE]u8 = undefined;

const total = c.readLoop(fd, data.ptr, read_size);
if (total == 0) return false;
while (true) {
c.sleepNs(config.LOG_FOLLOW_POLL_NS);
if (c.pendingTermInt() != 0) break;

// Skip first partial line if we started mid-file
var start: usize = 0;
if (tail_start > 0) {
while (start < total and data[start] != '\n') start += 1;
if (start < total) start += 1;
}
for (targets) |*target| {
const fd = c.openRead(target.pathZ()) orelse continue;
defer c.closeFd(fd);

// Build prefix: [name YYYY-MM-DDTHH:MM:SS]
var prefix_buf: [MAX_NAME + TIMESTAMP_LEN + 4]u8 = undefined; // [name ts] + space
var ts_buf: [TIMESTAMP_LEN]u8 = undefined;
const ts_len = formatTimestamp(&ts_buf);
const prefix = std.fmt.bufPrint(&prefix_buf, "[{s} {s}] ", .{ name, ts_buf[0..ts_len] }) catch return false;
const file_size_off = c.fileSize(fd) orelse continue;
if (file_size_off <= 0) continue;
const fs: usize = @intCast(file_size_off);

// Write each line with prefix
var pos = start;
while (pos < total) {
var end = pos;
while (end < total and data[end] != '\n') end += 1;
if (end > pos) {
c.writeOnce(out_fd, prefix);
c.writeOnce(out_fd, data[pos..end]);
c.writeOnce(out_fd, "\n");
if (fs < target.offset) {
// File was truncated (rotation), reset to start
target.offset = 0;
}
if (fs <= target.offset) continue;

c.seekTo(fd, @intCast(target.offset));
const pfx = target.prefixSlice();

while (true) {
const n = c.read(fd, &buf, buf.len);
if (n <= 0) break;
outputLines(buf[0..@intCast(n)], pfx);
}

target.offset = fs;
Comment thread
kooksee marked this conversation as resolved.
Outdated
}
pos = end + 1;
}
return true;
}

// ─── Log Rotation ────────────────────────────────────────────────────────────
Expand Down
Loading