Feat: pty resize via named pipe

This commit is contained in:
unitwk 2024-03-11 17:17:44 +08:00
parent 161909f0e7
commit 50a7a5cb9e
5 changed files with 79 additions and 212 deletions

View File

@ -13,9 +13,8 @@ import PtyStartCommand from "./pty/pty_start";
import PtyStopCommand from "./pty/pty_stop";
import OpenFrpTask from "./task/openfrp";
import RconCommand from "./steam/rcon_command";
import NodePtyStartCommand from "./pty/node_pty_start";
import DockerResizeCommand from "./docker/docker_pty_resize";
import NodePtyResizeCommand from "./pty/node_pty_resize";
import PtyResizeCommand from "./pty/pty_resize";
// Instance function dispatcher
// Dispatch and assign different functions according to different types
@ -48,10 +47,9 @@ export default class FunctionDispatcher extends InstanceCommand {
// Enable emulated terminal mode
if (instance.config.terminalOption.pty && instance.config.processType === "general") {
// instance.setPreset("start", new PtyStartCommand());
instance.setPreset("start", new NodePtyStartCommand());
instance.setPreset("start", new PtyStartCommand());
instance.setPreset("stop", new PtyStopCommand());
instance.setPreset("resize", new NodePtyResizeCommand());
instance.setPreset("resize", new PtyResizeCommand());
}
// Whether to enable Docker PTY mode
if (instance.config.processType === "docker") {

View File

@ -1,21 +0,0 @@
import Instance from "../../instance/instance";
import InstanceCommand from "../base/command";
import { NodeProcessAdapter } from "./node_pty_start";
interface IResizeOptions {
h: number;
w: number;
}
export default class NodePtyResizeCommand extends InstanceCommand {
constructor() {
super("ResizeTTY");
}
async exec(instance: Instance, size?: IResizeOptions): Promise<any> {
const dockerProcess = instance.process as Partial<NodeProcessAdapter>;
if (typeof dockerProcess?.resize === "function") {
dockerProcess?.resize(size.w, size.h);
}
}
}

View File

@ -1,174 +0,0 @@
import { $t } from "../../../i18n";
import os from "os";
import Instance from "../../instance/instance";
import logger from "../../../service/log";
import fs from "fs-extra";
import path from "path";
import readline from "readline";
import InstanceCommand from "../base/command";
import { ChildProcess, ChildProcessWithoutNullStreams, spawn } from "child_process";
import { commandStringToArray } from "../base/command_parser";
import FunctionDispatcher from "../dispatcher";
import type { IPty, spawn as spawnType } from "node-pty";
import { killProcess } from "common";
import { EventEmitter } from "koa";
import { IInstanceProcess } from "../../instance/interface";
interface IPtySubProcessCfg {
pid: number;
}
// Error exception at startup
class StartupError extends Error {
constructor(msg: string) {
super(msg);
}
}
// process adapter
export class NodeProcessAdapter extends EventEmitter implements IInstanceProcess {
public pid?: number;
public exitCode: number;
constructor(private process: IPty) {
super();
this.pid = this.process.pid;
this.process.onData((data: string | Buffer) => this.emit("data", data));
this.process.onExit((info) => {
this.exitCode = info.exitCode;
this.emit("exit", info.exitCode);
});
}
public resize(w: number, h: number) {
this.process.resize(w, h);
}
public write(data?: string) {
return this.process.write(data);
}
public kill(s?: any) {
return killProcess(this.pid, this.process);
}
public async destroy() {
try {
// remove all dynamically added event listeners
for (const n of this.eventNames()) this.removeAllListeners(n);
if (this.exitCode) {
this.kill();
}
} catch (error) {}
}
}
export default class NodePtyStartCommand extends InstanceCommand {
constructor() {
super("PtyStartCommand");
}
readPtySubProcessConfig(subProcess: ChildProcessWithoutNullStreams): Promise<IPtySubProcessCfg> {
return new Promise((r, j) => {
const errConfig = {
pid: 0
};
const rl = readline.createInterface({
input: subProcess.stdout,
crlfDelay: Infinity
});
rl.on("line", (line = "") => {
try {
rl.removeAllListeners();
const cfg = JSON.parse(line) as IPtySubProcessCfg;
if (cfg.pid == null) throw new Error("Error");
r(cfg);
} catch (error) {
r(errConfig);
}
});
setTimeout(() => {
r(errConfig);
}, 1000 * 3);
});
}
async exec(instance: Instance, source = "Unknown") {
if (
!instance.config.startCommand ||
!instance.config.cwd ||
!instance.config.ie ||
!instance.config.oe
)
throw new StartupError($t("TXT_CODE_pty_start.cmdErr"));
if (!fs.existsSync(instance.absoluteCwdPath()))
throw new StartupError($t("TXT_CODE_pty_start.cwdNotExist"));
if (!path.isAbsolute(path.normalize(instance.config.cwd)))
throw new StartupError($t("TXT_CODE_pty_start.mustAbsolutePath"));
// PTY mode correctness check
logger.info($t("TXT_CODE_pty_start.startPty", { source: source }));
try {
require.resolve("node-pty");
} catch (e) {
// node-pty not available
instance.println("ERROR", $t("TXT_CODE_pty_start.startErr"));
instance.config.terminalOption.pty = false;
await instance.forceExec(new FunctionDispatcher());
await instance.execPreset("start", source);
return;
}
// command parsing
const commandList = commandStringToArray(instance.config.startCommand);
const commandExeFile = commandList[0];
const commandParameters = commandList.slice(1);
if (commandList.length === 0) {
throw new StartupError($t("TXT_CODE_general_start.cmdEmpty"));
}
logger.info("----------------");
logger.info($t("TXT_CODE_general_start.startInstance", { source: source }));
logger.info($t("TXT_CODE_general_start.instanceUuid", { uuid: instance.instanceUuid }));
logger.info($t("TXT_CODE_general_start.startCmd", { cmdList: JSON.stringify(commandList) }));
logger.info($t("TXT_CODE_general_start.cwd", { cwd: instance.config.cwd }));
logger.info("----------------");
const { spawn } = require("node-pty") as {
spawn: typeof spawnType;
};
logger.info($t("模式仿真终端node-pty"));
const ptyProcess = spawn(commandExeFile, commandParameters, {
name: "xterm-256color",
cols: 170,
rows: 40,
cwd: instance.config.cwd,
env: { ...process.env, TERM: "xterm-256color" },
encoding: null
});
if (!ptyProcess.pid) {
instance.println(
"ERROR",
$t("TXT_CODE_general_start.pidErr", {
startCommand: instance.config.startCommand,
commandExeFile: commandExeFile,
commandParameters: JSON.stringify(commandParameters)
})
);
throw new StartupError($t("TXT_CODE_general_start.startErr"));
}
const processAdapter = new NodeProcessAdapter(ptyProcess);
instance.started(processAdapter);
logger.info(
$t("TXT_CODE_pty_start.startSuccess", {
instanceUuid: instance.instanceUuid,
pid: processAdapter.pid
})
);
instance.println("INFO", $t("TXT_CODE_pty_start.startEmulatedTerminal"));
}
}

View File

@ -0,0 +1,21 @@
import Instance from "../../instance/instance";
import InstanceCommand from "../base/command";
import { GoPtyProcessAdapter } from "./pty_start";
interface IResizeOptions {
h: number;
w: number;
}
export default class PtyResizeCommand extends InstanceCommand {
constructor() {
super("ResizeTTY");
}
async exec(instance: Instance, size?: IResizeOptions): Promise<any> {
const pty = instance.process as Partial<GoPtyProcessAdapter>;
if (typeof pty?.resize === "function") {
pty?.resize(size.w, size.h);
}
}
}

View File

@ -11,10 +11,9 @@ import { IInstanceProcess } from "../../instance/interface";
import { ChildProcess, ChildProcessWithoutNullStreams, exec, spawn } from "child_process";
import { commandStringToArray } from "../base/command_parser";
import { killProcess } from "common";
import GeneralStartCommand from "../general/general_start";
import FunctionDispatcher from "../dispatcher";
import StartCommand from "../start";
import { PTY_PATH } from "../../../const";
import { Writable } from "stream";
interface IPtySubProcessCfg {
pid: number;
@ -27,16 +26,47 @@ class StartupError extends Error {
}
}
const GO_PTY_MSG_TYPE = {
RESIZE: 0x04
};
// process adapter
export class GoPtyProcessAdapter extends EventEmitter implements IInstanceProcess {
pid?: number | string;
private pipeClient: Writable;
constructor(private process: ChildProcess, ptySubProcessPid: number) {
constructor(private process: ChildProcess, public pid: number, public pipeName: string) {
super();
this.pid = ptySubProcessPid;
process.stdout.on("data", (text) => this.emit("data", text));
process.stderr.on("data", (text) => this.emit("data", text));
process.on("exit", (code) => this.emit("exit", code));
this.initNamedPipe();
}
private initNamedPipe() {
const fd = fs.openSync(this.pipeName, "w");
const writePipe = fs.createWriteStream(null, { fd });
writePipe.on("close", () => {});
writePipe.on("end", () => {});
writePipe.on("error", (err) => {
logger.error("Pipe error:", this.pipeName, err);
});
this.pipeClient = writePipe;
}
public resize(w: number, h: number) {
const MAX_W = 900;
if (w > MAX_W) w = MAX_W;
if (h > MAX_W) h = MAX_W;
const resizeStruct = JSON.stringify({ width: Number(w), height: Number(h) });
const len = resizeStruct.length;
const lenBuff = Buffer.alloc(2);
lenBuff.writeInt16BE(len, 0);
const buf = Buffer.from([GO_PTY_MSG_TYPE.RESIZE, ...lenBuff, ...Buffer.from(resizeStruct)]);
this.writeToNamedPipe(buf);
}
public writeToNamedPipe(data: Buffer) {
this.pipeClient.write(data);
}
public write(data?: string) {
@ -60,6 +90,10 @@ export class GoPtyProcessAdapter extends EventEmitter implements IInstanceProces
if (this.process)
for (const eventName of this.process.eventNames())
this.process.stdout.removeAllListeners(eventName);
if (this.pipeClient)
for (const eventName of this.pipeClient.eventNames())
this.pipeClient.removeAllListeners(eventName);
this.pipeClient?.destroy();
this.process?.stdout?.destroy();
this.process?.stderr?.destroy();
if (this.process?.exitCode === null) {
@ -146,16 +180,25 @@ export default class PtyStartCommand extends InstanceCommand {
if (commandList.length === 0)
return instance.failure(new StartupError($t("TXT_CODE_pty_start.cmdEmpty")));
const pipeLinuxDir = "/tmp/mcsmanager-instance-pipe";
if (!fs.existsSync(pipeLinuxDir)) fs.mkdirsSync(pipeLinuxDir);
let pipeName = `${pipeLinuxDir}/pipe-${instance.instanceUuid}`;
if (os.platform() === "win32") {
pipeName = `\\\\.\\pipe\\${pipeName}`;
}
const ptyParameter = [
"-dir",
instance.config.cwd,
"-cmd",
JSON.stringify(commandList),
"-size",
`${instance.config.terminalOption.ptyWindowCol},${instance.config.terminalOption.ptyWindowRow}`,
"-color",
"-coder",
instance.config.oe
instance.config.oe,
"-dir",
instance.config.cwd,
"-fifo",
pipeName,
"-cmd",
JSON.stringify(commandList)
];
logger.info("----------------");
@ -190,7 +233,7 @@ export default class PtyStartCommand extends InstanceCommand {
// create process adapter
const ptySubProcessCfg = await this.readPtySubProcessConfig(subProcess);
const processAdapter = new GoPtyProcessAdapter(subProcess, ptySubProcessCfg.pid);
const processAdapter = new GoPtyProcessAdapter(subProcess, ptySubProcessCfg.pid, pipeName);
logger.info(`pty.exe response: ${JSON.stringify(ptySubProcessCfg)}`);
// After reading the configuration, Need to check the process status