plugin-process-manager.js 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  1. import { spawn } from 'child_process';
  2. import path from 'path';
  3. import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
  4. // Map<pluginName, { process, port }>
  5. const runningPlugins = new Map();
  6. // Map<pluginName, Promise<port>> — in-flight start operations
  7. const startingPlugins = new Map();
  8. /**
  9. * Start a plugin's server subprocess.
  10. * The plugin's server entry must print a JSON line with { ready: true, port: <number> }
  11. * to stdout within 10 seconds.
  12. */
  13. export function startPluginServer(name, pluginDir, serverEntry) {
  14. if (runningPlugins.has(name)) {
  15. return Promise.resolve(runningPlugins.get(name).port);
  16. }
  17. // Coalesce concurrent starts for the same plugin
  18. if (startingPlugins.has(name)) {
  19. return startingPlugins.get(name);
  20. }
  21. const startPromise = new Promise((resolve, reject) => {
  22. const serverPath = path.join(pluginDir, serverEntry);
  23. // Restricted env — only essentials, no host secrets
  24. const pluginProcess = spawn('node', [serverPath], {
  25. cwd: pluginDir,
  26. env: {
  27. PATH: process.env.PATH,
  28. HOME: process.env.HOME,
  29. NODE_ENV: process.env.NODE_ENV || 'production',
  30. PLUGIN_NAME: name,
  31. },
  32. stdio: ['ignore', 'pipe', 'pipe'],
  33. });
  34. let resolved = false;
  35. let stdout = '';
  36. const timeout = setTimeout(() => {
  37. if (!resolved) {
  38. resolved = true;
  39. pluginProcess.kill();
  40. reject(new Error('Plugin server did not report ready within 10 seconds'));
  41. }
  42. }, 10000);
  43. pluginProcess.stdout.on('data', (data) => {
  44. if (resolved) return;
  45. stdout += data.toString();
  46. // Look for the JSON ready line
  47. const lines = stdout.split('\n');
  48. for (const line of lines) {
  49. try {
  50. const msg = JSON.parse(line.trim());
  51. if (msg.ready && typeof msg.port === 'number') {
  52. clearTimeout(timeout);
  53. resolved = true;
  54. runningPlugins.set(name, { process: pluginProcess, port: msg.port });
  55. pluginProcess.on('exit', () => {
  56. runningPlugins.delete(name);
  57. });
  58. console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`);
  59. resolve(msg.port);
  60. }
  61. } catch {
  62. // Not JSON yet, keep buffering
  63. }
  64. }
  65. });
  66. pluginProcess.stderr.on('data', (data) => {
  67. console.warn(`[Plugin:${name}] ${data.toString().trim()}`);
  68. });
  69. pluginProcess.on('error', (err) => {
  70. clearTimeout(timeout);
  71. if (!resolved) {
  72. resolved = true;
  73. reject(new Error(`Failed to start plugin server: ${err.message}`));
  74. }
  75. });
  76. pluginProcess.on('exit', (code) => {
  77. clearTimeout(timeout);
  78. runningPlugins.delete(name);
  79. if (!resolved) {
  80. resolved = true;
  81. reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
  82. }
  83. });
  84. }).finally(() => {
  85. startingPlugins.delete(name);
  86. });
  87. startingPlugins.set(name, startPromise);
  88. return startPromise;
  89. }
  90. /**
  91. * Stop a plugin's server subprocess.
  92. * Returns a Promise that resolves when the process has fully exited.
  93. */
  94. export function stopPluginServer(name) {
  95. const entry = runningPlugins.get(name);
  96. if (!entry) return Promise.resolve();
  97. return new Promise((resolve) => {
  98. const cleanup = () => {
  99. clearTimeout(forceKillTimer);
  100. runningPlugins.delete(name);
  101. resolve();
  102. };
  103. entry.process.once('exit', cleanup);
  104. entry.process.kill('SIGTERM');
  105. // Force kill after 5 seconds if still running
  106. const forceKillTimer = setTimeout(() => {
  107. if (runningPlugins.has(name)) {
  108. entry.process.kill('SIGKILL');
  109. cleanup();
  110. }
  111. }, 5000);
  112. console.log(`[Plugins] Server stopped for "${name}"`);
  113. });
  114. }
  115. /**
  116. * Get the port a running plugin server is listening on.
  117. */
  118. export function getPluginPort(name) {
  119. return runningPlugins.get(name)?.port ?? null;
  120. }
  121. /**
  122. * Check if a plugin's server is running.
  123. */
  124. export function isPluginRunning(name) {
  125. return runningPlugins.has(name);
  126. }
  127. /**
  128. * Stop all running plugin servers (called on host shutdown).
  129. */
  130. export function stopAllPlugins() {
  131. const stops = [];
  132. for (const [name] of runningPlugins) {
  133. stops.push(stopPluginServer(name));
  134. }
  135. return Promise.all(stops);
  136. }
  137. /**
  138. * Start servers for all enabled plugins that have a server entry.
  139. * Called once on host server boot.
  140. */
  141. export async function startEnabledPluginServers() {
  142. const plugins = scanPlugins();
  143. const config = getPluginsConfig();
  144. for (const plugin of plugins) {
  145. if (!plugin.server) continue;
  146. if (config[plugin.name]?.enabled === false) continue;
  147. const pluginDir = getPluginDir(plugin.name);
  148. if (!pluginDir) continue;
  149. try {
  150. await startPluginServer(plugin.name, pluginDir, plugin.server);
  151. } catch (err) {
  152. console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
  153. }
  154. }
  155. }