From 26216889defd12a40f372bfce96f11afd8a8e680 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Sun, 20 Sep 2026 18:19:52 +0800 Subject: [PATCH] =?UTF-8?q?feat(viper):=20=E6=96=B0=E5=A2=9E=E6=89=8B?= =?UTF-8?q?=E5=8A=A8=E6=8C=87=E5=AE=9A=20exe=20=E7=9A=84=20VET=20check=20?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 viper.checkEnvironment(命令面板 + Environments 视图标题按钮):选择 exe 后经 check 通道校验 PE/.PRVERSN 与 --version 前缀 - 回执按回带的 id 配对;check_msg 九种原因(NOT_FOUND/NO_PRVERSN/TIMEOUT/CHANNEL_UNAVAILABLE 等)映射为可读说明 - stdin 写入改为入队后仅在心跳窗口内逐行送出(check 在前、心跳最后、行间 300ms、按窗口时长限量),规避看门狗对同批多行的读取行为 - 进程退出/停止时唤醒等待中的请求,避免调用方悬挂 --- packages/viper/package.json | 14 ++ packages/viper/src/environmentTool.ts | 262 ++++++++++++++++++++++++-- packages/viper/src/extension.ts | 87 ++++++++- packages/viper/src/types.ts | 12 ++ packages/vscode-vipeye/package.json | 14 ++ 5 files changed, 372 insertions(+), 17 deletions(-) diff --git a/packages/viper/package.json b/packages/viper/package.json index da7e622c..3c910fc7 100644 --- a/packages/viper/package.json +++ b/packages/viper/package.json @@ -146,6 +146,12 @@ "category": "Viper", "icon": "$(output)" }, + { + "command": "viper.checkEnvironment", + "title": "Check Viper Executable...", + "category": "Viper", + "icon": "$(check)" + }, { "command": "viper.refreshView", "title": "Refresh", @@ -172,6 +178,9 @@ { "command": "viper.showEnvironmentLog" }, + { + "command": "viper.checkEnvironment" + }, { "command": "viper.openVpjAsJson", "when": "resourceExtname == .vpj" @@ -192,6 +201,11 @@ } ], "view/title": [ + { + "command": "viper.checkEnvironment", + "when": "view == viper.environmentView", + "group": "navigation" + }, { "command": "viper.refreshView", "when": "view == viper.projectView || view == viper.environmentView || view == viper.toolchainView || view == viper.settingsView", diff --git a/packages/viper/src/environmentTool.ts b/packages/viper/src/environmentTool.ts index 621946c9..24e3bf25 100644 --- a/packages/viper/src/environmentTool.ts +++ b/packages/viper/src/environmentTool.ts @@ -20,7 +20,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { commands, Disposable, Event, EventEmitter, OutputChannel, window, workspace } from 'vscode'; -import { ViperEnvironment } from './types'; +import { ViperCheckResult, ViperEnvironment } from './types'; /** * VET 进程状态。 @@ -34,6 +34,29 @@ export type EnvironmentToolState = 'disabled' | 'stopped' | 'starting' | 'runnin const HealthyRunMs = 30000; /** 两次心跳之间的最小间隔,避免同一轮重复发送。 */ const MinConfirmIntervalMs = 1500; +/** + * check 请求的等待上限:看门狗只在心跳窗口内读 stdin(窗口外的请求要等下一个窗口, + * 最长 confirmSeconds),父进程还要等到下一个轮询点,故给足余量。 + */ +const MinCheckTimeoutMs = 30000; +const ExtraCheckTimeoutMs = 20000; +/** + * 两次 stdin 写入之间的间隔。 + * 原因:VET 看门狗读 stdin 时,若同一批 ReadFile 一次读到多行,只保留第一行、 + * 其余字节被丢弃(VGuard.ReadConfirmLine 的已知缺陷),下次读就会拼出错位的残行, + * 被判定为"非 {"ok": 1} 的输入"从而**强杀整个 scan 进程**。 + * 因此所有写入都排在心跳窗口内、逐行送出并留出本间隔,让每次 ReadFile 只含一行。 + */ +const StdinLineGapMs = 300; +/** 每个心跳窗口留给写入的时长(窗口本身是 confirmTimeoutMs,留出余量避免踩线)。 */ +const StdinWindowBudgetMs = 1000; + +/** 一条尚未收到回执的 check 请求。 */ +interface PendingCheck { + Resolve: (result: ViperCheckResult) => void; + Reject: (error: Error) => void; + Timer: NodeJS.Timeout; +} export class EnvironmentTool implements Disposable { private process: ChildProcessWithoutNullStreams | undefined; @@ -48,6 +71,15 @@ export class EnvironmentTool implements Disposable { private disposed = false; /** 上次记录过的退出信息,避免状态栏/提示重复报同一件事。 */ private lastExitSummary = ''; + /** check 请求 id:进程内递增即可(VET 原样回带,仅用于配对)。 */ + private nextCheckId = 1; + /** 尚未收到回执的 check 请求。 */ + private readonly pendingChecks = new Map(); + /** 待写入 stdin 的 check 请求行(只在心跳窗口内逐行送出,见 BeginConfirmWindow)。 */ + private readonly stdinQueue: string[] = []; + /** 本次窗口是否还需要补一行心跳。 */ + private pendingHeartbeat = false; + private stdinWriting = false; private readonly channel: OutputChannel; @@ -103,6 +135,110 @@ export class EnvironmentTool implements Disposable { this.SetState('stopped'); } + /** + * 通过 VET 的 check 通道检查一个 exe(用户手动指定位置时用)。 + * + * 通道形态:请求从 **stdin** 写入一行 `{"check": "<绝对路径>", "id": <整数>}`, + * 由看门狗(confirm-watch)识别后经匿名管道转发给 scan;回执是 **stdout** 的一行 JSON, + * 顶层 `id` 回带请求 id。故 check 通道依附心跳看门狗:没开 --need-confirm 就没有该通道。 + * + * 通过后 VET 会把该项入库并写入缓存文件,下一轮(≤ scanIntervalMs)快照里就会出现它。 + */ + CheckExecutable(exePath: string): Promise { + const config = this.ReadConfig(); + if (config.confirmSeconds <= 0) { + return Promise.reject( + new Error( + 'check 通道依附心跳看门狗:请把 viper.environmentTool.confirmSeconds 设为大于 0 的值(例如 10)。' + ) + ); + } + + const child = this.process; + if (!child || !child.stdin.writable) { + return Promise.reject(new Error(`VET 未在运行(当前状态:${DescribeStateForMessage(this.state)})。`)); + } + + const id = this.nextCheckId++; + const timeoutMs = Math.max(MinCheckTimeoutMs, config.confirmSeconds * 1000 + ExtraCheckTimeoutMs); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pendingChecks.delete(id); + reject( + new Error( + `等待 VET 回执超时(${Math.round(timeoutMs / 1000)} 秒)。请确认 VET 正在运行、且心跳确认已开启。` + ) + ); + }, timeoutMs); + + this.pendingChecks.set(id, { Resolve: resolve, Reject: reject, Timer: timer }); + + // 只入队:等下一个心跳窗口才送出(窗口外写入会被积压成多行,见 BeginConfirmWindow)。 + // 路径里的反斜杠/引号等必须转义;VET 侧用 json 库解析这一行。 + this.stdinQueue.push(`${JSON.stringify({ check: exePath, id })}\n`); + this.AppendLog(`[vscode] 发出 check 请求 #${id}: ${exePath}`); + }); + } + + /** + * 逐行送出 stdin 队列。 + * + * 顺序有讲究:**先 check 请求,最后才发心跳**。因为看门狗一旦确认 ok=1 就立刻离开 + * 等待循环(回去等下一个 interval),此后写进去的行只能积压到下一个窗口,下个窗口 + * 又会与新的心跳凑成同一批 —— 正是要避免的局面。而 check 行会被转发后继续留在 + * 等待循环里,所以只要逐行、留间隔,每一行都会被单独读走。 + * + * 每窗口发送量按窗口时长封顶,保证心跳一定能在窗口内发出。 + */ + private async DrainStdinQueue(): Promise { + if (this.stdinWriting) { + return; + } + this.stdinWriting = true; + try { + const config = this.ReadConfig(); + const budget = Math.max(0, config.confirmTimeoutMs - StdinWindowBudgetMs); + const maxChecks = Math.max(0, Math.floor(budget / StdinLineGapMs)); + + let sent = 0; + while (this.stdinQueue.length > 0 && sent < maxChecks) { + if (!this.WriteOneLine(this.stdinQueue.shift() as string)) { + return; + } + sent++; + // 只在后面还有内容要写时才等间隔(省掉最后一次多余的等待)。 + if (this.stdinQueue.length > 0 || this.pendingHeartbeat) { + await Delay(StdinLineGapMs); + } + } + + if (this.pendingHeartbeat) { + this.pendingHeartbeat = false; + this.WriteOneLine('{"ok":1}\n'); + } + } finally { + this.stdinWriting = false; + } + } + + /** 写一行到 VET 的 stdin;返回 false 表示进程已不可写(并清空队列)。 */ + private WriteOneLine(line: string): boolean { + const child = this.process; + if (!child || !child.stdin.writable) { + this.stdinQueue.length = 0; + this.pendingHeartbeat = false; + return false; + } + try { + child.stdin.write(line); + return true; + } catch (e) { + this.AppendLog(`[vscode] 写入 stdin 失败: ${String(e)}`); + return true; // 单行失败不放弃整个队列(异常多为 CLOSED,下一行会走上面的分支) + } + } + /** 追加一行日志(进 "Vipeye VET" 输出通道),供扩展其它部分记录与 VET 相关的信息。 */ AppendLog(line: string): void { this.channel.appendLine(line); @@ -197,6 +333,7 @@ export class EnvironmentTool implements Disposable { const aliveMs = Date.now() - this.startedAt; this.AppendLog(`[vscode] VET 退出: code=${code} signal=${signal ?? '-'} 存活 ${aliveMs} ms`); + this.RejectPendingChecks('VET 进程已退出,check 请求未完成。'); if (this.disposed) { return; @@ -239,6 +376,7 @@ export class EnvironmentTool implements Disposable { const child = this.process; this.process = undefined; + this.RejectPendingChecks('VET 进程已停止,check 请求未完成。'); if (!child) { return; } @@ -340,11 +478,65 @@ export class EnvironmentTool implements Disposable { return; } + // check 回执:{"check_result": bool, "id": <请求 id>, "viper"|"check_msg": …} + if (typeof parsed === 'object' && parsed !== null && 'check_result' in parsed) { + this.HandleCheckReply(parsed as Record); + return; + } + // {"exit":1, "msg":"timeout/ok!=1"} —— VET 因确认失败而终止。 const exitInfo = parsed as { exit?: number; msg?: string }; this.AppendLog(`[vscode] VET 报告退出: exit=${exitInfo.exit ?? '?'} msg=${exitInfo.msg ?? ''}`); } + /** 处理 check 回执:按回带的 id 唤醒等待中的请求。 */ + private HandleCheckReply(reply: Record): void { + const id = Number(reply.id); + const pending = this.pendingChecks.get(id); + if (!pending) { + // 超时后迟到的回执,或不是本扩展发出的请求:只记录,不当作错误。 + this.AppendLog(`[vscode] 收到无人等待的 check 回执: id=${reply.id}`); + return; + } + + this.pendingChecks.delete(id); + clearTimeout(pending.Timer); + + const ok = reply.check_result === true; + const raw = reply.viper; + const environment = + ok && typeof raw === 'object' && raw !== null + ? this.ToEnvironment(raw as Record) + : undefined; + + this.AppendLog( + ok + ? `[vscode] check #${id} 通过: ${environment?.DisplayName ?? '(无详情)'}` + : `[vscode] check #${id} 未通过: ${String(reply.check_msg ?? '未知原因')}` + ); + + pending.Resolve({ + Ok: ok, + Id: id, + Environment: environment, + Message: typeof reply.check_msg === 'string' ? reply.check_msg : undefined, + }); + } + + /** 进程消失时:丢弃待写的 stdin 行,并唤醒所有等待中的 check 请求,避免调用方一直挂着。 */ + private RejectPendingChecks(reason: string): void { + this.stdinQueue.length = 0; + this.pendingHeartbeat = false; + if (this.pendingChecks.size === 0) { + return; + } + for (const [, pending] of this.pendingChecks) { + clearTimeout(pending.Timer); + pending.Reject(new Error(reason)); + } + this.pendingChecks.clear(); + } + /** 把 VET 输出的 JSON 条目规整为强类型(字段名转 PascalCase,缺字段给空串)。 */ private ToEnvironments(entries: unknown[]): ViperEnvironment[] { return entries @@ -382,35 +574,37 @@ export class EnvironmentTool implements Disposable { // stderr 全部原样进 "Vipeye VET"(含 [scan] 进度行),不做过滤。 this.channel.appendLine(line); - // 每轮结束会提示进入确认窗口,这时才回 {"ok":1}(窗口只有几秒,必须立刻回)。 + // 每轮结束会提示进入确认窗口(两行:请在… / 当前… 本轮期限…)。 if (line.includes('本轮期限') || line.includes('[confirm] 请在')) { - this.SendConfirm(); + this.BeginConfirmWindow(); } } - /** 向 VET 的 stdin 写入一行 {"ok":1}。 */ - private SendConfirm(): void { + /** + * 心跳窗口打开:看门狗此刻正在等 stdin,是**唯一安全**的写入时机。 + * 窗口外写入会在管道里积压成多行,被看门狗一次 ReadFile 读走时只保留第一行、 + * 其余字节丢弃 ⇒ 拼出错位残行 ⇒ 判定失败并强杀 scan 进程(见 StdinLineGapMs)。 + * 故:心跳与 check 请求都只入队,统一在这里开始逐行送出(行间留间隔)。 + */ + private BeginConfirmWindow(): void { const child = this.process; if (!child || !child.stdin.writable) { return; } - // 提示是两行(请在… / 当前… 本轮期限…),间隔很短;去重避免同一窗口重复发送。 + // 提示是两行,间隔很短;此处去重避免同一窗口重复插心跳。 if (Date.now() - this.lastConfirmAt < MinConfirmIntervalMs) { return; } this.lastConfirmAt = Date.now(); - try { - child.stdin.write('{"ok":1}\n'); - this.AppendLog('[vscode] 已发送心跳 {"ok":1}'); - } catch (e) { - this.AppendLog(`[vscode] 心跳写入失败: ${String(e)}`); - } + this.pendingHeartbeat = true; + void this.DrainStdinQueue(); } /** - * 心跳兜底:万一没识别到确认提示(例如 VET 日志措辞变化), + * 心跳兜底:万一一直没识别到确认提示(例如 VET 改了措辞), * 超过「确认周期 + 5 秒」没有发送过就补一次,避免 VET 因超时自杀。 + * 这是异常路径,只能无视窗口直接写;为免与积压行凑成一批,先丢弃排队中的请求。 */ private EnsureWatchdog(): void { if (this.watchdogTimer) { @@ -419,11 +613,24 @@ export class EnvironmentTool implements Disposable { this.watchdogTimer = setInterval(() => { const config = this.ReadConfig(); - if (config.confirmSeconds <= 0 || !this.process) { + const child = this.process; + if (config.confirmSeconds <= 0 || !child) { return; } - if (Date.now() - this.lastConfirmAt > (config.confirmSeconds + 5) * 1000) { - this.SendConfirm(); + if (Date.now() - this.lastConfirmAt <= (config.confirmSeconds + 5) * 1000) { + return; + } + + this.stdinQueue.length = 0; + if (!child.stdin.writable) { + return; + } + this.lastConfirmAt = Date.now(); + try { + child.stdin.write('{"ok":1}\n'); + this.AppendLog('[vscode] 兜底发送心跳(未识别到确认窗口提示)'); + } catch (e) { + this.AppendLog(`[vscode] 兜底心跳写入失败: ${String(e)}`); } }, 2000); } @@ -461,3 +668,26 @@ interface EnvironmentToolConfig { scanIntervalMs: number; maxRestarts: number; } + +/** 简单延时(用于 stdin 逐行写入的间隔)。 */ +function Delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** 状态的简短中文描述(用于错误消息)。 */ +function DescribeStateForMessage(state: EnvironmentToolState): string { + switch (state) { + case 'running': + return '运行中'; + case 'starting': + return '启动中'; + case 'restarting': + return '重启中'; + case 'stopped': + return '已手动关闭'; + case 'failed': + return '已停止(连续失败)'; + default: + return '未启用'; + } +} diff --git a/packages/viper/src/extension.ts b/packages/viper/src/extension.ts index f47e0179..4e804c3a 100644 --- a/packages/viper/src/extension.ts +++ b/packages/viper/src/extension.ts @@ -3,11 +3,13 @@ * Viper Core 扩展入口。 */ -import { commands, ExtensionContext, window, workspace } from 'vscode'; +import * as path from 'path'; +import { commands, ExtensionContext, ProgressLocation, window, workspace } from 'vscode'; import { EnvironmentManager } from './environmentManager'; import { EnvironmentTool } from './environmentTool'; import { ProjectManager } from './projectManager'; import { ToolchainManager } from './toolchainManager'; +import { ViperCheckResult } from './types'; import { ViperViewProvider } from './viperViewProvider'; import { EnvironmentStatusBar } from './statusBar'; import { ViperApiImpl } from './api'; @@ -132,6 +134,9 @@ export async function activate(context: ExtensionContext): Promise RegisterCommandSafe(context, 'viper.showEnvironmentLog', () => { environmentTool.ShowLog(); }), + RegisterCommandSafe(context, 'viper.checkEnvironment', async () => { + await CheckEnvironmentCommand(environmentTool); + }), RegisterCommandSafe(context, 'viper.openVpjAsJson', async (uri?: import('vscode').Uri) => { const target = uri ?? window.activeTextEditor?.document.uri; if (!target) { @@ -238,6 +243,86 @@ async function CreateProjectCommand(): Promise { window.showInformationMessage(`Created ${uri.fsPath}`); } +/** + * 手动指定一个 exe,经 VET 的 check 通道校验(PE/.PRVERSN + `--version` 前缀为 Viper)。 + * 校验通过后 VET 会把它入库并写回缓存文件,下一轮快照里就会出现在 Environments 视图。 + */ +async function CheckEnvironmentCommand(tool: EnvironmentTool): Promise { + const picked = await window.showOpenDialog({ + title: '选择要检查的 Viper 可执行文件', + canSelectMany: false, + openLabel: '检查', + filters: { 'Viper 可执行文件': ['exe'] }, + }); + const exePath = picked?.[0]?.fsPath; + if (!exePath) { + return; + } + + await window.withProgress( + { location: ProgressLocation.Notification, title: `VET 正在检查 ${path.basename(exePath)}…` }, + async () => { + let result: ViperCheckResult; + try { + result = await tool.CheckExecutable(exePath); + } catch (e) { + void window.showErrorMessage(`VET 检查失败:${e instanceof Error ? e.message : String(e)}`); + return; + } + + const environment = result.Environment; + if (result.Ok && environment) { + void window + .showInformationMessage( + `VET 检查通过:${environment.DisplayName}(${environment.Arch})`, + '选择为当前环境', + '显示详情' + ) + .then((choice) => { + if (choice === '选择为当前环境') { + void commands.executeCommand('viper.selectEnvironment', environment.Id); + } else if (choice === '显示详情') { + void window.showInformationMessage(environment.Executable, { modal: true }); + } + }); + return; + } + + void window.showErrorMessage(`VET 检查未通过:${DescribeCheckMessage(result.Message)}`); + } + ); +} + +/** 把 VET 回执里的 check_msg 固定原因串翻译成可读说明。 */ +function DescribeCheckMessage(message: string | undefined): string { + if (!message) { + return '未知原因(VET 未给出 check_msg)'; + } + switch (message) { + case 'NOT_FOUND': + return '文件不存在'; + case 'NO_PERMISSION': + return '无法打开该文件(权限不足或被占用)'; + case 'NOT_EXE': + return '不是有效的 PE 可执行文件'; + case 'NO_PRVERSN': + return '缺少 .PRVERSN 段(不是 TransPyV 工具链产物)'; + case 'PRVERSN_ERR': + return '.PRVERSN 段定位或读取失败'; + case 'PREFIX_ERR': + return '--version 输出不以 Viper 开头'; + case 'TIMEOUT': + return '--version 探测超时'; + case 'CHANNEL_UNAVAILABLE': + return 'VET 的 check 通道不可用(内部缓冲未就绪)'; + default: + // EXE_ERR,0x:拉起失败,错误码原样带上 + return message.startsWith('EXE_ERR,') + ? `无法启动该 exe(错误码 ${message.slice('EXE_ERR,'.length)})` + : message; + } +} + /** 显示当前项目信息。 */ function ShowProjectInfoCommand(projects: ProjectManager): void { const project = projects.GetProject(); diff --git a/packages/viper/src/types.ts b/packages/viper/src/types.ts index db28ac94..195f821c 100644 --- a/packages/viper/src/types.ts +++ b/packages/viper/src/types.ts @@ -42,6 +42,18 @@ export interface ViperEnvironment { DisplayName: string; // 展示名,如「Viper 0.0.3-dev.428」 } +/** VET check 通道的单次检查回执。 */ +export interface ViperCheckResult { + /** 检查是否通过(回执的 check_result)。 */ + Ok: boolean; + /** 请求时带的 id;VET 原样回带,仅用于把回执与请求配对。 */ + Id: number; + /** 通过时的发现项(回执的 viper 子对象,source 固定为 check)。 */ + Environment?: ViperEnvironment; + /** 未通过时的固定原因串(回执的 check_msg,如 NOT_FOUND / NO_PRVERSN)。 */ + Message?: string; +} + /** 暴露给其他扩展的 API。 */ export interface ViperApi { /** 获取当前工作区的项目配置。 */ diff --git a/packages/vscode-vipeye/package.json b/packages/vscode-vipeye/package.json index dd4cf74f..fbe1c416 100644 --- a/packages/vscode-vipeye/package.json +++ b/packages/vscode-vipeye/package.json @@ -295,6 +295,12 @@ "category": "Viper", "icon": "$(output)" }, + { + "command": "viper.checkEnvironment", + "title": "Check Viper Executable...", + "category": "Viper", + "icon": "$(check)" + }, { "command": "viper.refreshView", "title": "Refresh", @@ -365,6 +371,9 @@ { "command": "viper.showEnvironmentLog" }, + { + "command": "viper.checkEnvironment" + }, { "command": "viper.openVpjAsJson", "when": "resourceExtname == .vpj" @@ -385,6 +394,11 @@ } ], "view/title": [ + { + "command": "viper.checkEnvironment", + "when": "view == viper.environmentView", + "group": "navigation" + }, { "command": "viper.refreshView", "when": "view == viper.projectView || view == viper.environmentView || view == viper.toolchainView || view == viper.settingsView",