Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions apps/web/app/api/control/runtime/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { execFile, spawn } from "node:child_process";
import path from "node:path";
import { promisify } from "node:util";

import { NextResponse } from "next/server";
Expand All @@ -23,6 +24,16 @@ const LABELS: Record<RuntimeService, string> = {
web: "com.media-studio.web",
};

function getMediaRoot() {
if (process.env.MEDIA_STUDIO_DATA_ROOT) {
return path.dirname(process.env.MEDIA_STUDIO_DATA_ROOT);
}
if (process.cwd().endsWith(path.join("apps", "web"))) {
return path.resolve(process.cwd(), "..", "..");
}
return process.cwd();
}

async function listLaunchdLines() {
try {
const { stdout } = await execFileAsync("launchctl", ["list"]);
Expand All @@ -45,7 +56,66 @@ function parseLaunchdLine(lines: string[], label: string) {
};
}

function normalizeCommandText(value: string | null | undefined) {
return String(value ?? "").toLowerCase().replaceAll("\\", "/");
}

function commandIncludesAppRoot(command: string, appRoot: string) {
const normalized = normalizeCommandText(command);
const normalizedRoot = normalizeCommandText(appRoot);
return normalized.includes(normalizedRoot) || normalized.includes("media-studio");
}

function commandMatchesService(command: string, service: RuntimeService, appRoot: string) {
if (!commandIncludesAppRoot(command, appRoot)) {
return false;
}
const normalized = normalizeCommandText(command);
if (service === "api") {
return normalized.includes("scripts/dev_api.mjs") || normalized.includes("uvicorn app.main:app");
}
return (
normalized.includes("next/dist/bin/next") ||
normalized.includes("next start") ||
normalized.includes("next dev") ||
normalized.includes("npm run start:web")
);
}

async function hasWindowsManualProcess(service: RuntimeService) {
const appRoot = getMediaRoot();
const escapedRoot = appRoot.replaceAll("'", "''");
try {
const { stdout } = await execFileAsync("powershell", [
"-NoProfile",
"-NonInteractive",
"-Command",
`
$ErrorActionPreference = 'SilentlyContinue'
$root = '${escapedRoot}'
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -and $_.CommandLine.Contains($root) } |
Select-Object ProcessId, CommandLine |
ConvertTo-Json -Compress
`,
]);
const trimmed = stdout.trim();
if (!trimmed) {
return false;
}
const parsed = JSON.parse(trimmed) as { CommandLine?: string } | Array<{ CommandLine?: string }>;
const processes = Array.isArray(parsed) ? parsed : [parsed];
return processes.some((processInfo) => commandMatchesService(processInfo.CommandLine ?? "", service, appRoot));
} catch {
return false;
}
}

async function hasManualProcess(service: RuntimeService) {
if (process.platform === "win32") {
return hasWindowsManualProcess(service);
}

const pattern =
service === "api"
? "uvicorn app.main:app"
Expand Down
123 changes: 122 additions & 1 deletion scripts/run_studio.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
const children = new Set();
let shuttingDown = false;
let activePaths = null;
let activeRuntime = null;

function usage() {
console.log(
Expand Down Expand Up @@ -127,6 +128,121 @@ function terminateChild(child) {
terminatePid(child.pid);
}

function normalizeCommandText(value) {
return String(value || "").toLowerCase().replaceAll("\\", "/");
}

function commandLooksLikeMediaStudio(command, root = mediaRoot) {
const normalized = normalizeCommandText(command);
const normalizedRoot = normalizeCommandText(root);
if (!normalized.includes(normalizedRoot) && !normalized.includes("media-studio")) {
return false;
}
return (
normalized.includes("scripts/run_studio.mjs") ||
normalized.includes("scripts/dev_api.mjs") ||
normalized.includes("uvicorn app.main:app") ||
normalized.includes("next/dist/bin/next") ||
normalized.includes("next start") ||
normalized.includes("next dev")
);
}

function runWindowsProcessQuery(script) {
if (process.platform !== "win32") {
return [];
}
const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status !== 0 || !result.stdout.trim()) {
return [];
}
try {
const parsed = JSON.parse(result.stdout);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return [];
}
}

function windowsListeningProcesses(port) {
const numericPort = Number.parseInt(String(port), 10);
if (!Number.isInteger(numericPort)) {
return [];
}
return runWindowsProcessQuery(`
$ErrorActionPreference = 'SilentlyContinue'
Get-NetTCPConnection -State Listen -LocalPort ${numericPort} |
Select-Object -ExpandProperty OwningProcess -Unique |
ForEach-Object {
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $_"
if ($process) {
[pscustomobject]@{ ProcessId = $process.ProcessId; CommandLine = $process.CommandLine }
}
} |
ConvertTo-Json -Compress
`);
}

function windowsMediaStudioProcesses(root = mediaRoot) {
const escapedRoot = root.replaceAll("'", "''");
return runWindowsProcessQuery(`
$ErrorActionPreference = 'SilentlyContinue'
$root = '${escapedRoot}'
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -and $_.CommandLine.Contains($root) } |
Select-Object ProcessId, CommandLine |
ConvertTo-Json -Compress
`);
}

function refreshWindowsPidFileFromPort(pidFile, port) {
if (process.platform !== "win32" || !pidFile || !port) {
return;
}
const deadline = Date.now() + 10000;
const refresh = () => {
const match = windowsListeningProcesses(port).find((entry) =>
commandLooksLikeMediaStudio(entry.CommandLine),
);
if (match?.ProcessId) {
writeFileSync(pidFile, String(match.ProcessId));
return;
}
if (Date.now() < deadline && !shuttingDown) {
setTimeout(refresh, 250).unref();
}
};
setTimeout(refresh, 250).unref();
}

function cleanupWindowsRuntimeProcesses(runtime = activeRuntime) {
if (process.platform !== "win32") {
return;
}

const processIds = new Set();
for (const port of [runtime?.webPort, runtime?.apiPort]) {
for (const entry of windowsListeningProcesses(port)) {
if (entry.ProcessId && commandLooksLikeMediaStudio(entry.CommandLine)) {
processIds.add(String(entry.ProcessId));
}
}
}
for (const entry of windowsMediaStudioProcesses(mediaRoot)) {
if (entry.ProcessId && commandLooksLikeMediaStudio(entry.CommandLine)) {
processIds.add(String(entry.ProcessId));
}
}

processIds.delete(String(process.pid));
for (const pid of processIds) {
terminatePid(pid);
}
}

function removeRuntimeFiles(paths) {
for (const file of [paths.apiPidFile, paths.webPidFile, paths.launcherPidFile]) {
rmSync(file, { force: true });
Expand All @@ -141,6 +257,7 @@ function shutdown(paths = activePaths, exitCode = 0) {
for (const child of children) {
terminateChild(child);
}
cleanupWindowsRuntimeProcesses();
if (paths) {
removeRuntimeFiles(paths);
}
Expand Down Expand Up @@ -183,7 +300,7 @@ function windowsCommandShim(command, args) {
};
}

function startProcess(label, command, args, env, { logFile, pidFile } = {}) {
function startProcess(label, command, args, env, { logFile, pidFile, listenPort } = {}) {
const invocation = windowsCommandShim(command, args);
const child = spawn(invocation.command, invocation.args, {
cwd: mediaRoot,
Expand All @@ -196,6 +313,7 @@ function startProcess(label, command, args, env, { logFile, pidFile } = {}) {
pipeWithPrefix(child.stderr, label, logStream);
if (pidFile && child.pid) {
writeFileSync(pidFile, String(child.pid));
refreshWindowsPidFileFromPort(pidFile, listenPort);
}
child.on("exit", (code, signal) => {
children.delete(child);
Expand Down Expand Up @@ -597,6 +715,7 @@ async function resolveAvailablePorts(runtime, options) {
async function main() {
const options = parseArgs(process.argv.slice(2));
const runtime = withResolvedRuntimeEnv({ ...options, reload: !options.production });
activeRuntime = runtime;
const paths = runtimePaths(mediaRoot, runtime.env);
activePaths = paths;
for (const signal of ["SIGINT", "SIGTERM"]) {
Expand Down Expand Up @@ -652,6 +771,7 @@ async function main() {
startProcess("api", process.execPath, apiArgs, runtime.env, {
logFile: paths.apiLog,
pidFile: paths.apiPidFile,
listenPort: runtime.apiPort,
});

const webArgs = options.production
Expand All @@ -661,6 +781,7 @@ async function main() {
startProcess("web", npmCommand(), webArgs, runtime.env, {
logFile: paths.webLog,
pidFile: paths.webPidFile,
listenPort: runtime.webPort,
});

console.log("Waiting for the API and Studio to become ready...");
Expand Down
96 changes: 96 additions & 0 deletions scripts/stop_studio.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,101 @@ function killPid(pid, { recursive = true } = {}) {
}
}

function normalizeCommandText(value) {
return String(value || "").toLowerCase().replaceAll("\\", "/");
}

function commandLooksLikeMediaStudio(command, mediaRoot) {
const normalized = normalizeCommandText(command);
const normalizedRoot = normalizeCommandText(mediaRoot);
if (!normalized.includes(normalizedRoot) && !normalized.includes("media-studio")) {
return false;
}
return (
normalized.includes("scripts/run_studio.mjs") ||
normalized.includes("scripts/dev_api.mjs") ||
normalized.includes("uvicorn app.main:app") ||
normalized.includes("next/dist/bin/next") ||
normalized.includes("next start") ||
normalized.includes("next dev")
);
}

function runWindowsProcessQuery(script) {
if (process.platform !== "win32") {
return [];
}
const result = spawnSync("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status !== 0 || !result.stdout.trim()) {
return [];
}
try {
const parsed = JSON.parse(result.stdout);
return Array.isArray(parsed) ? parsed : [parsed];
} catch {
return [];
}
}

function windowsListeningProcesses(port) {
const numericPort = Number.parseInt(String(port), 10);
if (!Number.isInteger(numericPort)) {
return [];
}
return runWindowsProcessQuery(`
$ErrorActionPreference = 'SilentlyContinue'
Get-NetTCPConnection -State Listen -LocalPort ${numericPort} |
Select-Object -ExpandProperty OwningProcess -Unique |
ForEach-Object {
$process = Get-CimInstance Win32_Process -Filter "ProcessId = $_"
if ($process) {
[pscustomobject]@{ ProcessId = $process.ProcessId; CommandLine = $process.CommandLine }
}
} |
ConvertTo-Json -Compress
`);
}

function windowsMediaStudioProcesses(root) {
const escapedRoot = root.replaceAll("'", "''");
return runWindowsProcessQuery(`
$ErrorActionPreference = 'SilentlyContinue'
$root = '${escapedRoot}'
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -and $_.CommandLine.Contains($root) } |
Select-Object ProcessId, CommandLine |
ConvertTo-Json -Compress
`);
}

function stopWindowsRuntimeProcesses(runtime, root) {
if (process.platform !== "win32") {
return;
}

const processIds = new Set();
for (const port of [runtime.webPort, runtime.apiPort]) {
for (const entry of windowsListeningProcesses(port)) {
if (entry.ProcessId && commandLooksLikeMediaStudio(entry.CommandLine, root)) {
processIds.add(String(entry.ProcessId));
}
}
}
for (const entry of windowsMediaStudioProcesses(root)) {
if (entry.ProcessId && commandLooksLikeMediaStudio(entry.CommandLine, root)) {
processIds.add(String(entry.ProcessId));
}
}

processIds.delete(String(process.pid));
for (const pid of processIds) {
killPid(pid);
}
}

function stopPidFile(pidFile, options = {}) {
const pid = readPid(pidFile);
killPid(pid, options);
Expand Down Expand Up @@ -124,6 +219,7 @@ function main() {
stopPidFile(paths.launcherPidFile, { recursive: false });
stopPidFile(paths.webPidFile);
stopPidFile(paths.apiPidFile);
stopWindowsRuntimeProcesses(runtime, mediaRoot);
stopUnixPort(runtime.webPort, mediaRoot);
stopUnixPort(runtime.apiPort, mediaRoot);
console.log(`Media Studio stopped for ports ${runtime.webPort} and ${runtime.apiPort}.`);
Expand Down