feat(viper): 新增手动指定 exe 的 VET check 检查

- 新增 viper.checkEnvironment(命令面板 + Environments 视图标题按钮):选择 exe 后经 check 通道校验 PE/.PRVERSN 与 --version 前缀
- 回执按回带的 id 配对;check_msg 九种原因(NOT_FOUND/NO_PRVERSN/TIMEOUT/CHANNEL_UNAVAILABLE 等)映射为可读说明
- stdin 写入改为入队后仅在心跳窗口内逐行送出(check 在前、心跳最后、行间 300ms、按窗口时长限量),规避看门狗对同批多行的读取行为
- 进程退出/停止时唤醒等待中的请求,避免调用方悬挂
This commit is contained in:
TermiNexus committed 2026-09-20 18:19:52 +08:00
1 parent 5d6fcf7c82
commit 26216889de
5 files changed
+372 -17

No files matched your search

+14
View File
@@ -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",
+246 -16
View File
@@ -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<number, PendingCheck>();
/** 待写入 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<ViperCheckResult> {
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<ViperCheckResult>((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<void> {
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<string, unknown>);
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<string, unknown>): 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<string, unknown>)
: 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<void> {
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 '未启用';
}
}
+86 -1
View File
@@ -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<ViperApiImpl>
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<void> {
window.showInformationMessage(`Created ${uri.fsPath}`);
}
/**
* 手动指定一个 exe,经 VET 的 check 通道校验(PE/.PRVERSN + `--version` 前缀为 Viper)。
* 校验通过后 VET 会把它入库并写回缓存文件,下一轮快照里就会出现在 Environments 视图。
*/
async function CheckEnvironmentCommand(tool: EnvironmentTool): Promise<void> {
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<HEX>:拉起失败,错误码原样带上
return message.startsWith('EXE_ERR,')
? `无法启动该 exe(错误码 ${message.slice('EXE_ERR,'.length)})`
: message;
}
}
/** 显示当前项目信息。 */
function ShowProjectInfoCommand(projects: ProjectManager): void {
const project = projects.GetProject();
+12
View File
@@ -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 {
/** 获取当前工作区的项目配置。 */
+14
View File
@@ -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",