feat: v0.7.4 时序语义修正 · 防线实效补漏 · 全量测试翻倍 — 2406 用例 + jsdom 组件测试全量回归
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m27s
CI / 产物编译验证 (push) Successful in 9m57s
CI / 全量测试 (Electron ABI) (push) Failing after 5m19s

P1 修复面收口:
- 超时三态区分(aborted→USER_INTERRUPT / ETIMEDOUT→TIMEOUT / 其余→ERROR),
  根治"真实网络超时被误报为用户中断"
- 流空闲超时统一(SSE/Ollama/Anthropic 读循环 60s 无数据抛 504 进重试通道)
- 同会话并发 sendMessage 防重入(isRunning 守卫)+ 会话存在性预检 +
  前置调用移入 try(ERROR+DONE 双事件保证,根治 isStreaming 假死)
- 清空审计后 resetChainCache(根治 verifyChain 误报 TAMPERED)
- DONE 不再提前清理 TRACE(TERMINATED 统一收尾,补全最终迭代录制)
- IME 合成回车不发送(普通 Enter + Cmd/Ctrl+Enter 双分支)+ handleSend 闭包修复

P2 安全纵深:
- preload 移除原始 electronAPI 暴露(渲染层零使用,关掉 XSS invoke 任意通道单点风险)
- CORS 同源回显根治(仅当前浏览页面 Origin,did-navigate 同步)
- MEMORY.md 命令保护正则扩展(括号/$/反引号/< 重定向边界 + 前导路径)
- write_file append TOCTOU 统一(open 后 realpath 校验,新文件分支补漏)
- 敏感键归一化(authKey 驼峰/连字符命中)+ MCP headers 鉴权值加密落库
- ReDoS 检测共享化(search_files/file_editor 统一拦截)
- run_tests/lint_code 升风险 + 需确认 + npx --no-install(执行边界对齐 run_command)
- MCP/SearXNG/llm.baseURL/updateFeedUrl 配置类 URL 高危目标校验(IPv6 去括号 +
  十六进制映射解析 + 尾点剥离)

P3 架构还债:
- temperature/maxTokens 热生效(引擎/编排器/SubAgent 三处接线)+ setBatch 单事务落盘
- SessionRecorder flush 竞态根治(flushPromise 等待 + 超限内联落盘 + stopRecording async)
- 内存收口(lastConsolidationBySession LRU / subTraces 清理 / 会话删除 disposeEngine)
- i18n 全量收口(28 组件 + 353 key 双字典,状态标签改渲染时函数)
- 死代码清理(updateTraceStep/HEADER_HEIGHT/void preA/失实注释)
- 斜杠菜单 MUI 化 + 删除逻辑收敛 resetSessionState + Blob URL 统一释放 +
  用户消息"仅保存"落库(saveMessage 透传前端 id 修复 id 错位)

P4 能力演进:
- 死循环检测拆分(驻留前置 + 乒乓后置带进度信号,合法交替不误报)
- run-lock 30s 超时强制 abort(旧 run 卡死不无限排队)
- RETRY 双通道 stream_reset(前端按 run 归属精确清空,根治重试文本重复)
- FTS5 trigram 中文子串搜索(迁移 9 版本化 SCHEMA_VERSION=2,≤2 字符 LIKE 回退)
- getContextWindow 兜底 1M→128K(未知模型防 413)

测试:
- 855 → 2406 用例(+1551,2.8 倍):服务层 +325(含 MemoryManager 51 新用例)、
  工具实体 +483、IPC/适配器 +390(含 OpenAI/Anthropic/Ollama 独立套件)、
  纯函数表格化 +330;引入 jsdom + @testing-library(14 组件测试文件 249 用例)
- 修复 R1(saveMessage id 透传)/ R2(stream_reset 精确归属)两个回归缺陷
- 遗留低危项清零:git-tools 顺序耦合 / web-fetch 真实时间退避 / slo 内存断言 /
  mcp-security 多余 skipIf / deepseek-balance 命名误导 / 组件 mock 注入脆弱性

版本: 0.7.4; README 同步(工具风险表/版本徽章); 依赖: 移除 @electron-toolkit/preload,
新增 jsdom/@testing-library(devDependencies 不打包)

回归: typecheck 双端 0 错误; ESLint 0/0; Electron ABI 全量 2406/2406 零跳过;
系统 Node 2110 通过 296 跳过(better-sqlite3 ABI)
This commit is contained in:
2026-08-30 19:19:07 +08:00
parent ebe45482b0
commit 99d0c54129
137 changed files with 25190 additions and 1792 deletions
+273 -85
View File
@@ -22,7 +22,20 @@
* @see standard/开发规范.md — 使用 fs/path 内置模块
*/
import { readFile, writeFile, readdir, stat, mkdir, open, unlink, rmdir, rm, rename, realpath, type FileHandle } from 'fs/promises';
import {
readFile,
writeFile,
readdir,
stat,
mkdir,
open,
unlink,
rmdir,
rm,
rename,
realpath,
type FileHandle,
} from 'fs/promises';
import { join, relative, resolve, dirname } from 'path';
import { existsSync, realpathSync } from 'fs';
import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool';
@@ -38,6 +51,7 @@ import {
decodeBufferWithDetection,
MAX_FILE_SIZE_BYTES,
FILE_TOOL_TIMEOUT_MS,
isPotentiallyCatastrophicRegex,
MAX_LINE_LENGTH,
} from './file-guard';
@@ -66,7 +80,11 @@ async function isBinaryFile(filePath: string): Promise<boolean> {
} finally {
// M-20 修复: 无论 read 成功或失败,都关闭文件句柄
if (fd) {
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
try {
await fd.close();
} catch {
/* 忽略关闭错误 */
}
}
}
}
@@ -90,9 +108,20 @@ export class ReadFileTool implements IMetonaTool {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Absolute or relative path to the file to read' },
offset: { type: 'number', description: 'Start line number (1-indexed, default 1). Ignored if tail is specified.' },
limit: { type: 'number', description: 'Maximum lines to read (default 500, max 2000). Ignored if tail is specified.' },
tail: { type: 'number', description: 'Read the last N lines from the file. Takes precedence over offset/limit. Useful for reading log tails. Max 2000.' },
offset: {
type: 'number',
description: 'Start line number (1-indexed, default 1). Ignored if tail is specified.',
},
limit: {
type: 'number',
description:
'Maximum lines to read (default 500, max 2000). Ignored if tail is specified.',
},
tail: {
type: 'number',
description:
'Read the last N lines from the file. Takes precedence over offset/limit. Useful for reading log tails. Max 2000.',
},
},
required: ['file_path'],
},
@@ -108,9 +137,10 @@ export class ReadFileTool implements IMetonaTool {
const offset = Math.max(1, (args.offset as number) ?? 1);
const limit = Math.min(2000, Math.max(1, (args.limit as number) ?? 500));
// F2-2: tail 模式 — 从文件末尾读取 N 行(优先于 offset/limit
const tail = args.tail !== undefined
? Math.min(2000, Math.max(1, Math.floor(args.tail as number)))
: undefined;
const tail =
args.tail !== undefined
? Math.min(2000, Math.max(1, Math.floor(args.tail as number)))
: undefined;
// v0.3.2: 文件存在性 + 大小预检(先 stat 再决定是否读取,避免大文件 OOM)
let stats;
@@ -137,7 +167,8 @@ export class ReadFileTool implements IMetonaTool {
returned_lines: 0,
truncated: false,
file_size: stats.size,
error: 'Binary file detected. Use view_image tool for images, or run_command for other binary content.',
error:
'Binary file detected. Use view_image tool for images, or run_command for other binary content.',
success: false,
};
}
@@ -192,13 +223,17 @@ export class WriteFileTool implements IMetonaTool {
readonly definition: MetonaToolDef = {
name: 'write_file',
description:
'Write content to a file. Creates the file if it doesn\'t exist, overwrites if it does. Supports append mode. Uses atomic write (temp file + rename) for safety. Content size limit: 10MB.',
"Write content to a file. Creates the file if it doesn't exist, overwrites if it does. Supports append mode. Uses atomic write (temp file + rename) for safety. Content size limit: 10MB.",
parameters: {
type: 'object',
properties: {
file_path: { type: 'string', description: 'Path to the file to write' },
content: { type: 'string', description: 'Content to write to the file' },
mode: { type: 'string', description: 'Write mode: "overwrite" (default) or "append"', enum: ['overwrite', 'append'] },
mode: {
type: 'string',
description: 'Write mode: "overwrite" (default) or "append"',
enum: ['overwrite', 'append'],
},
},
required: ['file_path', 'content'],
},
@@ -216,7 +251,10 @@ export class WriteFileTool implements IMetonaTool {
// v0.3.2: content 参数必须提供(undefined 时报错),但允许空字符串(用于创建空文件)
if (args.content === undefined) {
return { error: 'content is required (use empty string to create empty file)', success: false };
return {
error: 'content is required (use empty string to create empty file)',
success: false,
};
}
// v0.3.2: 内容大小上限(防止 OOM)
@@ -242,24 +280,56 @@ export class WriteFileTool implements IMetonaTool {
// - 大内容(> 4KB)非完全原子:可能被分成多个 write
// 改进:显式 open + 循环 write + fsync + close,保证数据持久化到磁盘
// 保持 append 语义(O_APPEND 内核级追加),不改变并发行为
// v0.7.4 P2-4: append 模式 TOCTOU 防护 —— safeResolvePath 校验时目标可能
// 是普通文件(realpath 通过),但 open(filePath,'a') 跟随符号链接;攻击者
// 在校验后把目标替换为指向工作空间外文件的 symlink,O_APPEND 会写入外部
// 文件。仿 delete_file 的 #20 双 realpath 比对:open 前记录 realpath 与
// 工作空间边界比对,open 后(写入前)再次比对,任一不一致即拒绝。
const fileExisted = existsSync(filePath);
const oldSize = fileExisted ? (await stat(filePath)).size : 0;
// v0.7.4 P2-4 修正: 统一 TOCTOU 校验 —— 已存在与新文件分支都走
// "open 后 realpath 边界校验"。旧实现新文件分支(!fileExisted)无校验:
// 攻击者在 existsSync=false 与 open('a') 之间放置指向工作空间外文件的
// symlinkO_APPEND 会跟随写入外部文件(正是本防护要防的形态)。
// 统一流程:open → realpath(filePath) → isPathWithinWorkspace 校验 →
// 校验通过才写入;失败拒绝并关闭 fd。
let fd: FileHandle | null = null;
try {
fd = await open(filePath, 'a');
// 'a' 模式下 write 追加到末尾(O_APPEND 内核级保证)
// 循环写入确保大内容完整写入(单次 write 可能不完整)
// open 后(写入前)realpath 校验 —— 防止目标被替换为工作空间外 symlink
let realAfter: string;
try {
realAfter = realpathSync(filePath);
} catch {
realAfter = filePath;
}
if (!isPathWithinWorkspace(realAfter, context.workspacePath)) {
return {
error: 'Path escape detected (TOCTOU protection)',
path: filePath,
success: false,
};
}
// 校验通过后正式执行追加写入
const buffer = Buffer.from(content, 'utf-8');
let totalWritten = 0;
while (totalWritten < buffer.length) {
const { bytesWritten } = await fd.write(buffer, totalWritten, buffer.length - totalWritten);
const { bytesWritten } = await fd.write(
buffer,
totalWritten,
buffer.length - totalWritten,
);
totalWritten += bytesWritten;
}
await fd.sync(); // fsync 保证数据持久化到磁盘
} finally {
if (fd) {
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
try {
await fd.close();
} catch {
/* 忽略关闭错误 */
}
}
}
@@ -283,7 +353,11 @@ export class WriteFileTool implements IMetonaTool {
} catch (err) {
// 原子写入失败,清理临时文件
if (existsSync(tmpPath)) {
try { await unlink(tmpPath); } catch { /* 忽略清理错误 */ }
try {
await unlink(tmpPath);
} catch {
/* 忽略清理错误 */
}
}
throw err;
}
@@ -312,8 +386,15 @@ export class ListDirectoryTool implements IMetonaTool {
properties: {
dir_path: { type: 'string', description: 'Directory path (default: workspace root)' },
depth: { type: 'number', description: 'Recursive depth (default 1, max 5)' },
glob: { type: 'string', description: 'Filename filter pattern. Supports comma-separated multi-glob (e.g., "*.ts" or "*.ts,*.js,*.tsx")' },
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
glob: {
type: 'string',
description:
'Filename filter pattern. Supports comma-separated multi-glob (e.g., "*.ts" or "*.ts,*.js,*.tsx")',
},
include_hidden: {
type: 'boolean',
description: 'Include hidden files/dirs starting with "." (default false)',
},
},
},
category: MetonaToolCategory.FILESYSTEM,
@@ -333,7 +414,13 @@ export class ListDirectoryTool implements IMetonaTool {
// v0.3.2 修复 WARN-3: listDir 内部提前终止,避免大目录全量遍历
const MAX_ENTRIES = 1000;
const entries: Array<{ name: string; path: string; type: string; size?: number; modified?: string }> = [];
const entries: Array<{
name: string;
path: string;
type: string;
size?: number;
modified?: string;
}> = [];
await this.listDir(dirPath, dirPath, depth, glob, includeHidden, 0, entries, MAX_ENTRIES);
return {
entries,
@@ -377,7 +464,16 @@ export class ListDirectoryTool implements IMetonaTool {
// 目录始终列出(不受 glob 过滤),保证递归可进入子目录
results.push({ name: entry.name, path: relativePath, type: 'directory' });
if (currentDepth < maxDepth - 1) {
await this.listDir(rootPath, fullPath, maxDepth, glob, includeHidden, currentDepth + 1, results, maxEntries);
await this.listDir(
rootPath,
fullPath,
maxDepth,
glob,
includeHidden,
currentDepth + 1,
results,
maxEntries,
);
}
} else {
// F2-4: glob 过滤仅适用于文件,支持多 glob(逗号分隔,如 "*.ts,*.js"
@@ -409,13 +505,31 @@ export class SearchFilesTool implements IMetonaTool {
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'Search pattern (regex for content, glob for filenames)' },
target: { type: 'string', description: '"content" (default) to search file contents, "files" to search filenames', enum: ['content', 'files'] },
pattern: {
type: 'string',
description: 'Search pattern (regex for content, glob for filenames)',
},
target: {
type: 'string',
description: '"content" (default) to search file contents, "files" to search filenames',
enum: ['content', 'files'],
},
path: { type: 'string', description: 'Search directory (default: workspace root)' },
file_glob: { type: 'string', description: 'Limit to specific file types. Supports comma-separated multi-glob (e.g., "*.py" or "*.ts,*.js,*.tsx")' },
file_glob: {
type: 'string',
description:
'Limit to specific file types. Supports comma-separated multi-glob (e.g., "*.py" or "*.ts,*.js,*.tsx")',
},
limit: { type: 'number', description: 'Maximum results (default 50, max 200)' },
context_lines: { type: 'number', description: 'Lines of context to show around content matches (default 0, max 5). Only for target="content".' },
include_hidden: { type: 'boolean', description: 'Include hidden files/dirs starting with "." (default false)' },
context_lines: {
type: 'number',
description:
'Lines of context to show around content matches (default 0, max 5). Only for target="content".',
},
include_hidden: {
type: 'boolean',
description: 'Include hidden files/dirs starting with "." (default false)',
},
},
required: ['pattern'],
},
@@ -438,9 +552,24 @@ export class SearchFilesTool implements IMetonaTool {
const includeHidden = (args.include_hidden as boolean) ?? false;
if (target === 'files') {
return await this.searchByFilename(searchPath, pattern, fileGlob, limit, includeHidden, context.workspacePath);
return await this.searchByFilename(
searchPath,
pattern,
fileGlob,
limit,
includeHidden,
context.workspacePath,
);
}
return await this.searchByContent(searchPath, pattern, fileGlob, limit, contextLines, includeHidden, context.workspacePath);
return await this.searchByContent(
searchPath,
pattern,
fileGlob,
limit,
contextLines,
includeHidden,
context.workspacePath,
);
} catch (error) {
return { error: extractErrorMessage(error), success: false };
}
@@ -456,12 +585,18 @@ export class SearchFilesTool implements IMetonaTool {
): Promise<unknown> {
const results: Array<{ path: string; name: string }> = [];
await this.walkDir(dirPath, async (filePath, name) => {
if (results.length >= limit) return;
if (matchGlob(name, pattern)) {
results.push({ path: relative(dirPath, filePath), name });
}
}, fileGlob, includeHidden, workspacePath);
await this.walkDir(
dirPath,
async (filePath, name) => {
if (results.length >= limit) return;
if (matchGlob(name, pattern)) {
results.push({ path: relative(dirPath, filePath), name });
}
},
fileGlob,
includeHidden,
workspacePath,
);
return { results, count: results.length, success: true };
}
@@ -479,7 +614,24 @@ export class SearchFilesTool implements IMetonaTool {
// 正则安全加固:限制 pattern 长度 + try-catch 防止 ReDoS
if (pattern.length > 500) {
return { results: [], count: 0, error: 'Search pattern too long (max 500 chars)', success: false };
return {
results: [],
count: 0,
error: 'Search pattern too long (max 500 chars)',
success: false,
};
}
// v0.7.4 P2-7: 灾难性正则(ReDoS)拦截 —— 与 file_editor 共用共享检测。
// 旧实现仅限制长度,`(a+)+$` 对超长行(单行可达 10MB 文件内)可指数级回溯
// 阻塞主进程事件循环。
if (isPotentiallyCatastrophicRegex(pattern)) {
return {
results: [],
count: 0,
error:
'Search pattern rejected: potentially catastrophic regex (ReDoS risk). Please simplify the pattern.',
success: false,
};
}
let regex: RegExp;
try {
@@ -488,50 +640,56 @@ export class SearchFilesTool implements IMetonaTool {
return { results: [], count: 0, error: `Invalid regex pattern: ${pattern}`, success: false };
}
await this.walkDir(dirPath, async (filePath) => {
if (results.length >= limit) return;
// 跳过工作空间根目录的 MEMORY.md(受保护文件,使用完整路径精确匹配)
if (isProtectedWorkspaceFile(filePath, workspacePath)) {
return;
}
try {
// v0.3.2: 跳过大文件(避免读取超大文件导致 OOM)
const fileStats = await stat(filePath);
if (fileStats.size > MAX_FILE_SIZE_BYTES) return;
// F2-3: 跳过二进制文件(避免读取乱码 + 提升性能)
// 空文件不算二进制,直接放行(size=0 时 isBinaryFile 内部 bytesRead=0 返回 false
if (fileStats.size > 0 && await isBinaryFile(filePath)) return;
// F2-3: 用智能编码检测读取文件(支持 GBK 等非 UTF-8 编码)
const buffer = await readFile(filePath);
const { content } = decodeBufferWithDetection(buffer);
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (results.length >= limit) break;
// v0.3.2 修复 regex bug: 每次匹配前重置 lastIndex,防止 g 标志导致漏匹配
regex.lastIndex = 0;
if (regex.test(lines[i])) {
const match = lines[i].trim().slice(0, 200);
// v0.3.2: context lines 支持
let context: string[] | undefined;
if (contextLines > 0) {
const start = Math.max(0, i - contextLines);
const end = Math.min(lines.length - 1, i + contextLines);
context = lines.slice(start, end + 1);
}
results.push({
path: relative(dirPath, filePath),
line: i + 1,
match,
context,
});
}
await this.walkDir(
dirPath,
async (filePath) => {
if (results.length >= limit) return;
// 跳过工作空间根目录的 MEMORY.md(受保护文件,使用完整路径精确匹配)
if (isProtectedWorkspaceFile(filePath, workspacePath)) {
return;
}
} catch {
// 跳过不可读文件
}
}, fileGlob, includeHidden, workspacePath);
try {
// v0.3.2: 跳过大文件(避免读取超大文件导致 OOM)
const fileStats = await stat(filePath);
if (fileStats.size > MAX_FILE_SIZE_BYTES) return;
// F2-3: 跳过二进制文件(避免读取乱码 + 提升性能)
// 空文件不算二进制,直接放行(size=0 时 isBinaryFile 内部 bytesRead=0 返回 false
if (fileStats.size > 0 && (await isBinaryFile(filePath))) return;
// F2-3: 用智能编码检测读取文件(支持 GBK 等非 UTF-8 编码)
const buffer = await readFile(filePath);
const { content } = decodeBufferWithDetection(buffer);
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
if (results.length >= limit) break;
// v0.3.2 修复 regex bug: 每次匹配前重置 lastIndex,防止 g 标志导致漏匹配
regex.lastIndex = 0;
if (regex.test(lines[i])) {
const match = lines[i].trim().slice(0, 200);
// v0.3.2: context lines 支持
let context: string[] | undefined;
if (contextLines > 0) {
const start = Math.max(0, i - contextLines);
const end = Math.min(lines.length - 1, i + contextLines);
context = lines.slice(start, end + 1);
}
results.push({
path: relative(dirPath, filePath),
line: i + 1,
match,
context,
});
}
}
} catch {
// 跳过不可读文件
}
},
fileGlob,
includeHidden,
workspacePath,
);
return { results, count: results.length, success: true };
}
@@ -570,7 +728,16 @@ export class SearchFilesTool implements IMetonaTool {
if (entry.isDirectory()) {
// #21 修复: 不跟随符号链接目录,防止循环遍历与越权访问
if (!entry.isSymbolicLink()) {
await this.walkDir(fullPath, callback, fileGlob, includeHidden, workspacePath, depth + 1, maxDepth, visited);
await this.walkDir(
fullPath,
callback,
fileGlob,
includeHidden,
workspacePath,
depth + 1,
maxDepth,
visited,
);
}
} else {
// F2-4: 支持多 glob(逗号分隔,如 "*.ts,*.js,*.tsx"
@@ -616,7 +783,8 @@ export class DeleteFileTool implements IMetonaTool {
file_path: { type: 'string', description: 'Path to the file or directory to delete' },
recursive: {
type: 'boolean',
description: 'Allow recursive deletion of non-empty directories (default false). Use with caution.',
description:
'Allow recursive deletion of non-empty directories (default false). Use with caution.',
},
},
required: ['file_path'],
@@ -671,7 +839,11 @@ export class DeleteFileTool implements IMetonaTool {
realBefore = resolvedPath;
}
if (!isPathWithinWorkspace(realBefore, context.workspacePath)) {
return { error: 'Path escape detected (TOCTOU protection)', path: filePath, success: false };
return {
error: 'Path escape detected (TOCTOU protection)',
path: filePath,
success: false,
};
}
const stats = await stat(resolvedPath);
@@ -685,7 +857,11 @@ export class DeleteFileTool implements IMetonaTool {
realAfter = resolvedPath;
}
if (realAfter !== realBefore) {
return { error: 'TOCTOU detected: file replaced during deletion', path: filePath, success: false };
return {
error: 'TOCTOU detected: file replaced during deletion',
path: filePath,
success: false,
};
}
if (isDirectory) {
@@ -710,7 +886,12 @@ export class DeleteFileTool implements IMetonaTool {
} catch (error) {
const errMsg = extractErrorMessage(error);
// 区分"目录非空"错误,给用户清晰提示
if (typeof error === 'object' && error !== null && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOTEMPTY') {
if (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as NodeJS.ErrnoException).code === 'ENOTEMPTY'
) {
return {
error: 'Directory not empty. Set recursive: true to delete non-empty directories.',
path: filePath,
@@ -745,7 +926,10 @@ export class FileMoveTool implements IMetonaTool {
properties: {
source_path: { type: 'string', description: 'Path to the file/directory to move' },
destination_path: { type: 'string', description: 'Destination path' },
overwrite: { type: 'boolean', description: 'Overwrite if destination exists (default false)' },
overwrite: {
type: 'boolean',
description: 'Overwrite if destination exists (default false)',
},
},
required: ['source_path', 'destination_path'],
},
@@ -917,7 +1101,11 @@ export class FileInfoTool implements IMetonaTool {
// 读取失败,不报告编码信息
} finally {
if (fd) {
try { await fd.close(); } catch { /* 忽略关闭错误 */ }
try {
await fd.close();
} catch {
/* 忽略关闭错误 */
}
}
}
}