diff --git a/packages/backend/rolldown.config.ts b/packages/backend/rolldown.config.ts index 8d8278fdd3..c454c915a0 100644 --- a/packages/backend/rolldown.config.ts +++ b/packages/backend/rolldown.config.ts @@ -3,6 +3,7 @@ import { version as summalyVersion } from '@misskey-dev/summaly'; import type { Plugin, ExternalOption } from 'rolldown'; import { execa, execaNode } from 'execa'; import type { ResultPromise } from 'execa'; +import fkill from 'fkill'; import esmShim from '@rollup/plugin-esm-shim'; /** @@ -10,6 +11,7 @@ import esmShim from '@rollup/plugin-esm-shim'; */ function backendDevServerPlugin(): Plugin { let backendProcess: ResultPromise | null = null; + let backendShutdownPromise: Promise | null = null; async function runBuildAssets() { await execa('pnpm', ['run', 'build-assets'], { @@ -20,12 +22,31 @@ function backendDevServerPlugin(): Plugin { } async function killBackendProcess() { - if (backendProcess) { - backendProcess.catch(() => {}); // backendProcess.kill()によって発生する例外を無視するためにcatch()を呼び出す - backendProcess.kill(); - await new Promise(resolve => backendProcess!.on('exit', resolve)); - backendProcess = null; - } + if (backendShutdownPromise) return backendShutdownPromise; + if (!backendProcess) return; + + const processToKill = backendProcess; + backendProcess = null; + processToKill.catch(() => {}); // プロセスの終了によって発生する例外を無視するためにcatch()を呼び出す + + backendShutdownPromise = (async () => { + if (process.platform === 'win32' && processToKill.pid != null) { + await fkill(processToKill.pid, { + force: true, + tree: true, + silent: true, + waitForExit: 5000, + }); + } else { + processToKill.kill(); + } + + await processToKill.catch(() => {}); + })().finally(() => { + backendShutdownPromise = null; + }); + + return backendShutdownPromise; } return { @@ -49,6 +70,9 @@ function backendDevServerPlugin(): Plugin { await runBuildAssets(); } }, + async closeWatcher() { + await killBackendProcess(); + }, }; } diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 54fba71f0f..3b7597182b 100644 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -10,104 +10,264 @@ import { execa } from 'execa'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); -await execa('pnpm', ['clean'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, +/** @type {Set} */ +const childProcesses = new Set(); +/** @type {Set} */ +const persistentChildProcesses = new Set(); +let shuttingDown = false; +let persistentChildProcessesStarted = false; +let persistentChildProcessFailed = false; +/** @type {Promise | null} */ +let shutdownPromise = null; + +/** + * 開発用コマンドを起動し、終了時にまとめて停止できるよう追跡する。 + * Windows では Ctrl+C の配信先を分離し、出力を親コンソールへ中継する。 + * + * @param {string} command - 実行するコマンド。 + * @param {string[]} args - コマンドへ渡す引数。 + * @param {import('execa').Options} options - execa の起動オプション。 + * @returns {import('execa').ResultPromise} 起動した子プロセス。 + */ +function spawnChildProcess(command, args, options) { + const isWindows = process.platform === 'win32'; + const pnpmPath = _dirname + '/../node_modules/pnpm/bin/pnpm.mjs'; + const windowsCommand = [process.execPath, pnpmPath, ...args] + .map(argument => `"${argument}"`) + .join(' '); + const executable = isWindows && command === 'pnpm' ? process.env.ComSpec ?? 'cmd.exe' : command; + const executableArgs = isWindows && command === 'pnpm' + ? ['/d', '/s', '/c', `start "" /b /wait ${windowsCommand}`] + : args; + const childProcess = execa(executable, executableArgs, isWindows ? { + ...options, + // `start /b` keeps the process in the current console without forwarding + // Ctrl+C, allowing only this supervisor to coordinate the shutdown. + windowsVerbatimArguments: true, + windowsHide: false, + stdout: 'pipe', + stderr: 'pipe', + buffer: false, + } : options); + + if (isWindows) { + if (options.stdout != null) childProcess.stdout?.pipe(options.stdout, { end: false }); + if (options.stderr != null) childProcess.stderr?.pipe(options.stderr, { end: false }); + } + + childProcesses.add(childProcess); + return childProcess; +} + +/** + * 子プロセスの終了を待機し、追跡対象から取り除く。 + * + * @param {string} command - 実行するコマンド。 + * @param {string[]} args - コマンドへ渡す引数。 + * @param {import('execa').Options} options - execa の起動オプション。 + * @returns {Promise} 子プロセスの実行結果。 + */ +async function runChildProcess(command, args, options) { + const childProcess = spawnChildProcess(command, args, options); + + try { + return await childProcess; + } finally { + childProcesses.delete(childProcess); + } +} + +/** + * 常駐する子プロセスがすべて終了していれば、終了結果を引き継いで親も終了する。 + * + * @returns {void} + */ +function shutdownIfAllPersistentChildProcessesStopped() { + if (shuttingDown || !persistentChildProcessesStarted || persistentChildProcesses.size > 0) return; + + void shutdown(persistentChildProcessFailed ? 1 : 0); +} + +/** + * 常駐する子プロセスを起動し、終了時の追跡解除・エラー出力・親の終了判定を設定する。 + * + * @param {string} command - 実行するコマンド。 + * @param {string[]} args - コマンドへ渡す引数。 + * @param {import('execa').Options} options - execa の起動オプション。 + * @returns {void} + */ +function startChildProcess(command, args, options) { + const childProcess = spawnChildProcess(command, args, options); + persistentChildProcesses.add(childProcess); + + void childProcess.then(() => { + childProcesses.delete(childProcess); + persistentChildProcesses.delete(childProcess); + shutdownIfAllPersistentChildProcessesStopped(); + }, error => { + childProcesses.delete(childProcess); + persistentChildProcesses.delete(childProcess); + if (!shuttingDown) { + persistentChildProcessFailed = true; + console.error(error); + shutdownIfAllPersistentChildProcessesStopped(); + } + }); +} + +/** + * 子プロセスとその配下のプロセスを停止し、終了を待機する。 + * + * @param {import('execa').ResultPromise} childProcess - 停止する子プロセス。 + * @returns {Promise} + */ +async function stopChildProcess(childProcess) { + if (process.platform === 'win32' && childProcess.pid != null) { + const result = await execa('taskkill', ['/pid', childProcess.pid.toString(), '/t', '/f'], { + reject: false, + }); + if (result.failed) childProcess.kill(); + } else { + childProcess.kill(); + } + + await childProcess.catch(() => {}); +} + +/** + * 追跡中の子プロセスを一度だけ停止して、指定した終了コードで終了する。 + * + * @param {number} exitCode - 親プロセスに返す終了コード。 + * @returns {Promise} 実行中または完了した停止処理。 + */ +function shutdown(exitCode) { + if (shutdownPromise != null) return shutdownPromise; + + shuttingDown = true; + shutdownPromise = (async () => { + await Promise.allSettled([...childProcesses].map(stopChildProcess)); + process.exit(exitCode); + })(); + + return shutdownPromise; +} + +process.on('SIGINT', () => { + void shutdown(0); }); -// アセットのビルドで依存しているので一番最初に必要 -await execa('pnpm', ['--filter', 'i18n', 'build'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, +process.on('SIGTERM', () => { + void shutdown(0); }); -await Promise.all([ - execa('pnpm', ['build-pre'], { +try { + await runChildProcess('pnpm', ['clean'], { cwd: _dirname + '/../', stdout: process.stdout, stderr: process.stderr, - }), - execa('pnpm', ['build-assets'], { + }); + + // アセットのビルドで依存しているので一番最初に必要 + await runChildProcess('pnpm', ['--filter', 'i18n', 'build'], { cwd: _dirname + '/../', stdout: process.stdout, stderr: process.stderr, - }), - execa('pnpm', ['--filter', 'backend...', '--filter=!backend', 'build'], { + }); + + await Promise.all([ + runChildProcess('pnpm', ['build-pre'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }), + runChildProcess('pnpm', ['build-assets'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }), + runChildProcess('pnpm', ['--filter', 'backend...', '--filter=!backend', 'build'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }), + // icons-subsetterは開発段階では使用されないが、型エラーを抑制するためにはじめの一度だけビルドする + runChildProcess('pnpm', ['--filter', 'icons-subsetter', 'build'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }), + runChildProcess('pnpm', ['--filter', 'misskey-js', 'build'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }), + ]); + + startChildProcess('pnpm', ['build-pre', '--watch'], { cwd: _dirname + '/../', stdout: process.stdout, stderr: process.stderr, - }), - // icons-subsetterは開発段階では使用されないが、型エラーを抑制するためにはじめの一度だけビルドする - execa('pnpm', ['--filter', 'icons-subsetter', 'build'], { + }); + + startChildProcess('pnpm', ['build-assets', '--watch'], { cwd: _dirname + '/../', stdout: process.stdout, stderr: process.stderr, - }), - execa('pnpm', ['--filter', 'misskey-js', 'build'], { + }); + + startChildProcess('pnpm', ['--filter', 'backend', 'dev'], { cwd: _dirname + '/../', stdout: process.stdout, stderr: process.stderr, - }), -]); + }); -execa('pnpm', ['build-pre', '--watch'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); + startChildProcess('pnpm', ['--filter', 'frontend', 'watch'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }); -execa('pnpm', ['build-assets', '--watch'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); + startChildProcess('pnpm', ['--filter', 'frontend-embed', 'watch'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }); -execa('pnpm', ['--filter', 'backend', 'dev'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); + startChildProcess('pnpm', ['--filter', 'sw', 'watch'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }); -execa('pnpm', ['--filter', 'frontend', 'watch'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); + startChildProcess('pnpm', ['--filter', 'misskey-js', 'watch', '--no-clean'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }); -execa('pnpm', ['--filter', 'frontend-embed', 'watch'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); + startChildProcess('pnpm', ['--filter', 'i18n', 'watch', '--no-clean'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }); -execa('pnpm', ['--filter', 'sw', 'watch'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); + startChildProcess('pnpm', ['--filter', 'misskey-reversi', 'watch', '--no-clean'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }); -execa('pnpm', ['--filter', 'misskey-js', 'watch', '--no-clean'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); + startChildProcess('pnpm', ['--filter', 'misskey-bubble-game', 'watch', '--no-clean'], { + cwd: _dirname + '/../', + stdout: process.stdout, + stderr: process.stderr, + }); -execa('pnpm', ['--filter', 'i18n', 'watch', '--no-clean'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); - -execa('pnpm', ['--filter', 'misskey-reversi', 'watch', '--no-clean'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); - -execa('pnpm', ['--filter', 'misskey-bubble-game', 'watch', '--no-clean'], { - cwd: _dirname + '/../', - stdout: process.stdout, - stderr: process.stderr, -}); + persistentChildProcessesStarted = true; + shutdownIfAllPersistentChildProcessesStopped(); +} catch (error) { + if (!shuttingDown) { + console.error(error); + await shutdown(1); + } +}