Skip to content
Open
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
22 changes: 6 additions & 16 deletions src/cli/commands/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ import { defineCommand } from 'citty';
import * as fs from 'node:fs';
import { spawn, execSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import {
checkControlPlaneHealth,
isMemorixBackgroundProcess,
} from './control-plane-shared.js';

// ============================================================
// Paths & Types
Expand Down Expand Up @@ -104,21 +108,7 @@ function killProcess(pid: number): boolean {
}
}

async function healthCheck(port: number, timeoutMs = 3000): Promise<{ ok: boolean; data?: any; error?: string }> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const res = await fetch(`http://127.0.0.1:${port}/api/team`, {
signal: controller.signal,
});
clearTimeout(timer);
if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
const data = await res.json();
return { ok: true, data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
const healthCheck = checkControlPlaneHealth;

async function isPortInUse(port: number): Promise<boolean> {
const health = await healthCheck(port, 1500);
Expand Down Expand Up @@ -358,7 +348,7 @@ async function doStatus(): Promise<void> {

// PID reuse guard: if PID is alive but health check fails, it's likely a different process
const health = running ? await healthCheck(state.port) : { ok: false, error: 'Process not running' };
const probablyReused = running && !health.ok && state.instanceToken;
const probablyReused = running && !health.ok && !!state.instanceToken && !isMemorixBackgroundProcess(state.pid);

console.log('');
console.log('Memorix Background Control Plane');
Expand Down
42 changes: 42 additions & 0 deletions src/cli/commands/control-plane-shared.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { execSync } from 'node:child_process';

export const CONTROL_PLANE_HEALTH_PATH = '/api/team';

export async function checkControlPlaneHealth(
port: number,
timeoutMs = 3000,
): Promise<{ ok: boolean; data?: any; error?: string }> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const res = await fetch(`http://127.0.0.1:${port}${CONTROL_PLANE_HEALTH_PATH}`, {
signal: controller.signal,
});
clearTimeout(timer);
Comment on lines +11 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear health-check abort timer on failure paths

The timeout is only cleared after a successful fetch, so when the request fails fast (for example, ECONNREFUSED) the function returns from catch but leaves a pending timer alive until timeoutMs elapses. Since doctor now uses this shared helper for its probes, failed checks can keep the CLI process running for an extra 2–3 seconds per probe even though the network failure happened immediately.

Useful? React with 👍 / 👎.

if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
const data = await res.json();
return { ok: true, data };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}

export function isLikelyMemorixServeHttpCommand(command: string): boolean {
return command.includes('memorix') && command.includes('serve-http');
}

export function isMemorixBackgroundProcess(pid: number, readProcessCommand = readProcessCommandForPid): boolean {
try {
const command = readProcessCommand(pid);
return isLikelyMemorixServeHttpCommand(command);
} catch {
return false;
}
}

export function readProcessCommandForPid(pid: number): string {
if (process.platform === 'linux') {
return execSync(`tr '\0' ' ' < /proc/${pid}/cmdline`, { encoding: 'utf-8' }).trim();
}
return execSync(`ps -p ${pid} -o command=`, { encoding: 'utf-8' }).trim();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Replace Unix-only process lookup for non-Linux platforms

The non-Linux branch runs ps -p ... -o command= to identify whether the PID belongs to memorix serve-http, but on Windows this command is not available under the default cmd.exe shell. In that environment isMemorixBackgroundProcess will always hit the catch path and return false, so background status can mislabel a real unhealthy Memorix PID as “reused” and delete background.json, leaving the running control plane unmanaged.

Useful? React with 👍 / 👎.

}
27 changes: 5 additions & 22 deletions src/cli/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { defineCommand } from 'citty';
import { checkControlPlaneHealth } from './control-plane-shared.js';

export default defineCommand({
meta: {
Expand Down Expand Up @@ -118,17 +119,8 @@ export default defineCommand({

// Health check
try {
const http = await import('node:http');
const healthy = await new Promise<boolean>((resolve) => {
const req = http.request({ hostname: '127.0.0.1', port: bgPort, path: '/health', timeout: 3000 }, (res) => {
res.resume();
resolve(res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.on('timeout', () => { req.destroy(); resolve(false); });
req.end();
});
if (healthy) {
const health = await checkControlPlaneHealth(bgPort, 3000);
if (health.ok) {
lines.push(ok('Health check: OK'));
} else {
lines.push(warn('Health check: FAILED (process alive but not responding)'));
Expand All @@ -148,17 +140,8 @@ export default defineCommand({
// Check for unmanaged foreground on default port
if (!bgRunning) {
try {
const http = await import('node:http');
const portUsed = await new Promise<boolean>((resolve) => {
const req = http.request({ hostname: '127.0.0.1', port: 3211, path: '/health', timeout: 2000 }, (res) => {
res.resume();
resolve(res.statusCode === 200);
});
req.on('error', () => resolve(false));
req.on('timeout', () => { req.destroy(); resolve(false); });
req.end();
});
if (portUsed) {
const portHealth = await checkControlPlaneHealth(3211, 2000);
if (portHealth.ok) {
lines.push(warn('Unmanaged foreground instance detected on port 3211'));
issues.push('A foreground "memorix serve-http" is running but not managed by background mode.');
(report.mode as any).unmanagedForeground = true;
Expand Down
28 changes: 28 additions & 0 deletions tests/cli/control-plane-shared.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';

import {
CONTROL_PLANE_HEALTH_PATH,
isLikelyMemorixServeHttpCommand,
isMemorixBackgroundProcess,
} from '../../src/cli/commands/control-plane-shared.js';

describe('control-plane-shared', () => {
it('uses the team API as the HTTP control-plane health endpoint', () => {
expect(CONTROL_PLANE_HEALTH_PATH).toBe('/api/team');
});

it('recognizes memorix serve-http commands', () => {
expect(isLikelyMemorixServeHttpCommand('node /tmp/memorix serve-http --port 3211')).toBe(true);
expect(isLikelyMemorixServeHttpCommand('/opt/homebrew/bin/node /Users/ravi/.bun/bin/memorix serve-http --port 3211')).toBe(true);
});

it('rejects unrelated commands for PID reuse checks', () => {
expect(isLikelyMemorixServeHttpCommand('node some-other-server.js')).toBe(false);
expect(isLikelyMemorixServeHttpCommand('memorix serve')).toBe(false);
});

it('only treats a pid as memorix when the inspected command matches serve-http', () => {
expect(isMemorixBackgroundProcess(123, () => 'node /Users/test/.bun/bin/memorix serve-http --port 3211')).toBe(true);
expect(isMemorixBackgroundProcess(123, () => 'node unrelated.js')).toBe(false);
});
});
Loading