Skip to content

Commit

Permalink
エンジンが使うポートが割り当てできなければ、他の空いているポートで起動してユーザーに通知する (#1267)
Browse files Browse the repository at this point in the history
Co-authored-by: Hiroshiba <hihokaruta@gmail.com>
  • Loading branch information
wappon28dev and Hiroshiba authored Apr 25, 2023
1 parent 14a30bc commit 5ce05b6
Show file tree
Hide file tree
Showing 15 changed files with 311 additions and 4 deletions.
4 changes: 4 additions & 0 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,10 @@ ipcMainHandle("GET_PRIVACY_POLICY_TEXT", () => {
return privacyPolicyText;
});

ipcMainHandle("GET_ALT_PORT_INFOS", () => {
return engineManager.altPortInfo;
});

ipcMainHandle("SHOW_AUDIO_SAVE_DIALOG", async (_, { title, defaultPath }) => {
const result = await dialog.showSaveDialog(win, {
title,
Expand Down
59 changes: 57 additions & 2 deletions src/background/engineManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import treeKill from "tree-kill";
import Store from "electron-store";
import shlex from "shlex";

import { BrowserWindow, dialog } from "electron";
import { app, BrowserWindow, dialog } from "electron";

import log from "electron-log";
import { z } from "zod";
import { PortManager } from "./portManager";
import { ipcMainSend } from "@/electron/ipc";

import {
Expand All @@ -20,6 +21,7 @@ import {
engineIdSchema,
minimumEngineManifestSchema,
} from "@/type/preload";
import { AltPortInfos } from "@/store/type";

type EngineProcessContainer = {
willQuitEngine: boolean;
Expand Down Expand Up @@ -66,6 +68,8 @@ export class EngineManager {
defaultEngineInfos: EngineInfo[];
engineProcessContainers: Record<EngineId, EngineProcessContainer>;

public altPortInfo: AltPortInfos = {};

constructor({
store,
defaultEngineDir,
Expand Down Expand Up @@ -209,6 +213,7 @@ export class EngineManager {
const engineInfo = engineInfos.find(
(engineInfo) => engineInfo.uuid === engineId
);

if (!engineInfo)
throw new Error(`No such engineInfo registered: engineId == ${engineId}`);

Expand All @@ -224,6 +229,51 @@ export class EngineManager {
return;
}

const engineInfoUrl = new URL(engineInfo.host);
const portManager = new PortManager(
engineInfoUrl.hostname,
parseInt(engineInfoUrl.port)
);

log.info(
`ENGINE ${engineId}: Checking whether port ${engineInfoUrl.port} is assignable...`
);

// ポートを既に割り当てられているプロセスidの取得: undefined → ポートが空いている
const processId = await portManager.getProcessIdFromPort();
if (processId !== undefined) {
const processName = await portManager.getProcessNameFromPid(processId);
log.warn(
`ENGINE ${engineId}: Port ${engineInfoUrl.port} has already been assigned by ${processName} (pid=${processId})`
);

// 代替ポート検索
const altPort = await portManager.findAltPort();

// 代替ポートが見つからないとき
if (!altPort) {
log.error(`ENGINE ${engineId}: No Alternative Port Found`);
dialog.showErrorBox(
`${engineInfo.name} の起動に失敗しました`,
`${engineInfoUrl.port}番ポートの代わりに利用可能なポートが見つかりませんでした。PCを再起動してください。`
);
app.exit(1);
throw new Error("No Alternative Port Found");
}

// 代替ポートの情報
this.altPortInfo[engineId] = {
from: parseInt(engineInfoUrl.port),
to: altPort,
};

// 代替ポートを設定
engineInfo.host = `http://${engineInfoUrl.hostname}:${altPort}`;
log.warn(
`ENGINE ${engineId}: Applied Alternative Port: ${engineInfoUrl.port} -> ${altPort}`
);
}

log.info(`ENGINE ${engineId}: Starting process`);

if (!(engineId in this.engineProcessContainers)) {
Expand All @@ -244,7 +294,12 @@ export class EngineManager {

// エンジンプロセスの起動
const enginePath = engineInfo.executionFilePath;
const args = engineInfo.executionArgs.concat(useGpu ? ["--use_gpu"] : []);
const args = engineInfo.executionArgs.concat(useGpu ? ["--use_gpu"] : [], [
"--host",
new URL(engineInfo.host).hostname,
"--port",
new URL(engineInfo.host).port,
]);

log.info(`ENGINE ${engineId} path: ${enginePath}`);
log.info(`ENGINE ${engineId} args: ${JSON.stringify(args)}`);
Expand Down
148 changes: 148 additions & 0 deletions src/background/portManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { execFileSync } from "child_process";
import log from "electron-log";

const isWindows = process.platform === "win32";

export class PortManager {
constructor(private hostname: string, private port: number) {}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
portLog = (...message: any) =>
log.info(`PORT ${this.port} (${this.hostname}): ${message}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
portWarn = (...message: any) =>
log.warn(`PORT ${this.port} (${this.hostname}): ${message}`);

/**
* "netstat -ano" の stdout から, 指定したポートを使用しているプロセスの process id を取得する
*
* ex) stdout:
* ``` cmd
* TCP 127.0.0.1:5173 127.0.0.1:50170 TIME_WAIT 0
* TCP 127.0.0.1:6463 0.0.0.0:0 LISTENING 18692
* TCP 127.0.0.1:50021 0.0.0.0:0 LISTENING 17320
* ```
* -> `17320`
*
* @param stdout netstat の stdout
* @returns `process id` or `undefined` (ポートが使用されていないとき)
*/
private stdout2processId(stdout: string): number | undefined {
const lines = stdout.split("\n");
for (const line of lines) {
if (line.includes(`${this.hostname}:${this.port}`)) {
const parts = line.trim().split(/\s+/);
return parseInt(parts[parts.length - 1], 10);
}
}
return undefined;
}

async getProcessIdFromPort(): Promise<number | undefined> {
this.portLog("Getting process id...");
const exec = isWindows
? {
cmd: "netstat",
args: ["-ano"],
}
: {
cmd: "lsof",
args: ["-i", `:${this.port}`, "-t", "-sTCP:LISTEN"],
};

this.portLog(`Running command: "${exec.cmd} ${exec.args.join(" ")}"`);

let stdout = execFileSync(exec.cmd, exec.args, {
shell: true,
}).toString();

if (isWindows) {
// Windows の場合は, lsof のように port と pid が 1to1 で取れないので, 3つのループバックアドレスが割り当てられているか確認
const loopbackAddr = ["127.0.0.1", "0.0.0.0", "[::1]"];

// hostname が3つループバックアドレスのどれかの場合, それぞれのループバックアドレスに対して pid を取得
if (loopbackAddr.includes(this.hostname)) {
this.portLog(
"Hostname is loopback address; Getting process id from all loopback addresses..."
);

const pid: (number | undefined)[] = [];
loopbackAddr.forEach((hostname) =>
pid.push(
// TODO: インスタンスの再定義を回避するなどのリファクタリング
new PortManager(hostname, this.port).stdout2processId(stdout)
)
);

// pid が undefined (= 割り当て可能) でないものを取得 → 1つ目を取得 → stdoutへ
stdout = pid.filter((pid) => pid !== undefined)[0]?.toString() ?? "";
} else {
stdout = this.stdout2processId(stdout)?.toString() ?? "";
}
}

if (!stdout || !stdout.length) {
this.portLog("Assignable; Nobody uses this port!");
return undefined;
}

this.portWarn(`Nonassignable; pid=${stdout} uses this port!`);
return parseInt(stdout);
}

async getProcessNameFromPid(pid: number): Promise<string> {
this.portLog(`Getting process name from pid=${pid}...`);
const exec = isWindows
? {
cmd: "wmic",
args: ["process", "where", `"ProcessID=${pid}"`, "get", "name"],
}
: {
cmd: "ps",
args: ["-p", pid.toString(), "-o", "comm="],
};

let stdout = execFileSync(exec.cmd, exec.args, { shell: true }).toString();

if (isWindows) {
/*
* ex) stdout:
* ```
* Name
* node.exe
* ```
* -> `node.exe`
*/
stdout = stdout.split("\r\n")[1];
}

this.portLog(`Found process name: ${stdout}`);
return stdout.trim();
}

/**
* 割り当て可能な他のポートを探します
*
* @returns 割り当て可能なポート番号 or `undefined` (割り当て可能なポートが見つからなかったとき)
*/
async findAltPort(): Promise<number | undefined> {
this.portLog(`Find another assignable port from ${this.port}...`);

// エンジン指定のポート + 100番までを探索 エフェメラルポートの範囲の最大は超えないようにする
const altPortMax = Math.min(this.port + 100, 65535);

for (let altPort = this.port + 1; altPort <= altPortMax; altPort++) {
this.portLog(`Trying whether port ${altPort} is assignable...`);
const altPid = await new PortManager( // TODO: インスタンスの再定義を回避するなどのリファクタリング
this.hostname,
altPort
).getProcessIdFromPort();

// ポートを既に割り当てられているプロセスidの取得: undefined → ポートが空いている
if (altPid === undefined) return altPort;
}

this.portWarn(`No alternative port found! ${this.port}...${altPortMax}`);
return undefined;
}
}
11 changes: 10 additions & 1 deletion src/components/MenuBar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,13 @@ export type MenuItemType = MenuItemData["type"];
const store = useStore();
const $q = useQuasar();
const currentVersion = ref("");
const altPorts = ref<number[]>([]);
store.dispatch("GET_ALT_PORT_INFOS").then(
(altPortInfo) =>
// {[engineId]: {from: number, to: number}} -> to: number[]
(altPorts.value = Object.values(altPortInfo).map(({ to }) => to))
);
window.electron.getAppInfos().then((obj) => {
currentVersion.value = obj.version;
});
Expand All @@ -103,7 +110,9 @@ const titleText = computed(
(projectName.value !== undefined ? projectName.value + " - " : "") +
"VOICEVOX" +
(currentVersion.value ? " - Ver. " + currentVersion.value : "") +
(isMultiEngineOffMode.value ? " - マルチエンジンオフ" : "")
(isMultiEngineOffMode.value ? " - マルチエンジンオフ" : "") +
// メインエンジン (0番目) の代替ポートの表示のみ
(altPorts.value.length ? " - Port: " + altPorts.value[0] : "")
);
// FIXME: App.vue内に移動する
Expand Down
4 changes: 4 additions & 0 deletions src/electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ const api: Sandbox = {
return await ipcRendererInvoke("GET_PRIVACY_POLICY_TEXT");
},

getAltPortInfos: async () => {
return await ipcRendererInvoke("GET_ALT_PORT_INFOS");
},

saveTempAudioFile: async ({ relativePath, buffer }) => {
if (!tempDir) {
tempDir = await ipcRendererInvoke("GET_TEMP_DIR");
Expand Down
3 changes: 2 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createApp } from "vue";
import { createGtm } from "@gtm-support/vue-gtm";
import { Quasar, Dialog, Loading } from "quasar";
import { Quasar, Dialog, Loading, Notify } from "quasar";
import iconSet from "quasar/icon-set/material-icons";
import App from "./App.vue";
import router from "./router";
Expand Down Expand Up @@ -39,6 +39,7 @@ createApp(App)
plugins: {
Dialog,
Loading,
Notify,
},
})
.use(ipcMessageReceiver, { store })
Expand Down
7 changes: 7 additions & 0 deletions src/store/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ export const engineStore = createPartialStore<EngineStoreTypes>({
});
},
},

GET_ALT_PORT_INFOS: {
async action() {
return await window.electron.getAltPortInfos();
},
},

SET_ENGINE_INFOS: {
mutation(
state,
Expand Down
1 change: 1 addition & 0 deletions src/store/setting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export const settingStoreState: SettingStoreState = {
},
confirmedTips: {
tweakableSliderByScroll: false,
engineStartedOnAltPort: false,
},
engineSettings: {},
};
Expand Down
6 changes: 6 additions & 0 deletions src/store/type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ export type Command = {
};

export type EngineState = "STARTING" | "FAILED_STARTING" | "ERROR" | "READY";
export type AltPortInfos = Record<EngineId, { from: number; to: number }>; // ポートが塞がれていたときの代替ポート

export type SaveResult =
| "SUCCESS"
| "WRITE_ERROR"
Expand Down Expand Up @@ -737,6 +739,10 @@ export type EngineStoreTypes = {
getter: EngineInfo[];
};

GET_ALT_PORT_INFOS: {
action(): Promise<AltPortInfos>;
};

SET_ENGINE_MANIFESTS: {
mutation: { engineManifests: Record<EngineId, EngineManifest> };
};
Expand Down
15 changes: 15 additions & 0 deletions src/styles/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ img {
background: transparent; // デフォルトの設定だと全画面ダイアログが出る際に黒背景がちらつく
}

// トースト通知内にあるボタン
.q-notification__actions .q-btn {
font-weight: bold;
}

// 設定とかのヘッダーの色
.q-layout__section--marginal {
background: colors.$toolbar !important;
Expand Down Expand Up @@ -232,3 +237,13 @@ img {
.bg-toolbar-button-display {
background: colors.$toolbar-button-display;
}

.bg-toast {
background: colors.$toast;
}
.text-toast-display {
color: colors.$toast-display;
}
.text-toast-button-display {
color: colors.$toast-button-display;
}
Loading

0 comments on commit 5ce05b6

Please sign in to comment.