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
89 changes: 89 additions & 0 deletions src/api-watcher-start.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { createApi } from './service/api.js';

async function startServer(app) {
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => resolve(server));
});
}

async function postJson(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
return { status: res.status, data: await res.json() };
}

describe('POST /internal/start-watcher heap args', () => {
let app;
let server;
let spawnCalls;
let indexer;

beforeEach(async () => {
const noRows = [];
const db = {
prepare() {
return {
all() { return noRows; },
get() { return { count: 0, min_path: null, max_path: null }; },
run() { return { changes: 0 }; }
};
}
};
const database = {
db,
projectExists() { return true; },
getDistinctProjects() { return []; }
};

spawnCalls = [];
const spawnProcess = (command, args, options) => {
spawnCalls.push({ command, args, options });
return {
on() {},
unref() {}
};
};

indexer = {
config: {
watcher: {
windowsRepoDir: 'C:\\repo'
}
}
};

app = createApi(database, indexer, null, { spawnProcess });
server = await startServer(app);
});

afterEach(() => {
if (app?._depthDebounceTimer) clearTimeout(app._depthDebounceTimer);
if (app?._watcherPruneInterval) clearInterval(app._watcherPruneInterval);
if (server) server.close();
});

it('uses normalized default heap for non-finite values', async () => {
indexer.config.watcher.maxOldSpaceSizeMb = 'Infinity';
const { port } = server.address();
const { status, data } = await postJson(`http://127.0.0.1:${port}/internal/start-watcher`, {});
assert.equal(status, 200);
assert.equal(data.ok, true);
assert.equal(spawnCalls.length, 1);
assert.ok(spawnCalls[0].args.includes('--max-old-space-size=8192'));
});

it('clamps negative heap values to minimum', async () => {
indexer.config.watcher.maxOldSpaceSizeMb = -1;
const { port } = server.address();
const { status, data } = await postJson(`http://127.0.0.1:${port}/internal/start-watcher`, {});
assert.equal(status, 200);
assert.equal(data.ok, true);
assert.equal(spawnCalls.length, 1);
assert.ok(spawnCalls[0].args.includes('--max-old-space-size=512'));
});
});
19 changes: 15 additions & 4 deletions src/service/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { writeFileSync, readFileSync, existsSync } from 'fs';
import { spawn, execSync } from 'child_process';
import { rankResults, groupResultsByFile } from './search-ranking.js';
import { contentHash } from './trigram.js';
import { buildWatcherCmdStartArgs } from '../watcher/watcher-launch.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const SLOW_QUERY_MS = 100;
Expand Down Expand Up @@ -189,7 +190,13 @@ function attachSignatures(results, database, fileIdResolver) {
}
}

export function createApi(database, indexer, queryPool = null, { zoektClient = null, zoektManager = null, zoektMirror = null, memoryIndex = null } = {}) {
export function createApi(database, indexer, queryPool = null, {
zoektClient = null,
zoektManager = null,
zoektMirror = null,
memoryIndex = null,
spawnProcess = spawn
} = {}) {
const app = express();

// Decompress gzip-encoded request bodies (watcher sends compressed payloads)
Expand Down Expand Up @@ -885,18 +892,22 @@ export function createApi(database, indexer, queryPool = null, { zoektClient = n

// The watcher runs on Windows — spawn via cmd.exe from WSL
const watcherScript = `${winRepoDir}\\src\\watcher\\watcher-client.js`;
const watcherStart = buildWatcherCmdStartArgs({
scriptPath: watcherScript,
maxOldSpaceSizeMb: indexer.config?.watcher?.maxOldSpaceSizeMb
});

try {
const child = spawn('/mnt/c/Windows/System32/cmd.exe',
['/c', 'start', 'Unreal Index Watcher', 'node', watcherScript], {
const child = spawnProcess('/mnt/c/Windows/System32/cmd.exe',
watcherStart.args, {
detached: true,
stdio: 'ignore'
});
child.on('error', err => {
console.error(`[API] Watcher spawn error: ${err.message}`);
});
child.unref();
console.log(`[API] Started watcher via cmd.exe: node ${watcherScript}`);
console.log(`[API] Started watcher via cmd.exe: node --max-old-space-size=${watcherStart.heapMb} ${watcherScript}`);
res.json({ ok: true, message: 'Watcher process started' });
} catch (err) {
console.error(`[API] Failed to start watcher: ${err.message}`);
Expand Down
20 changes: 10 additions & 10 deletions src/setup-gui.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
getWorkspaceMemoryLimitGB as _getWorkspaceMemoryLimitGB,
generateDockerComposeContent,
} from './workspace-utils.js';
import { startWorkspaceWatcher } from './setup-watcher-start.js';

// Prevent unhandled rejections from silently crashing the server
process.on('unhandledRejection', (reason) => {
Expand Down Expand Up @@ -1352,16 +1353,15 @@ route('POST', '/api/watcher/start', async (req, res) => {
return;
}

const watcherScript = join(ROOT, 'src', 'watcher', 'watcher-client.js');

// Spawn watcher as a detached process with --workspace flag
// Write stdout/stderr to a log file so we can diagnose crashes
const logPath = join(ROOT, `watcher-${wsName}.log`);
const logFd = openSync(logPath, 'a');
const child = spawn(process.execPath, [watcherScript, '--workspace', wsName], {
cwd: ROOT,
detached: true,
stdio: ['ignore', logFd, logFd]
// Use the per-workspace service config value for consistency with /internal/start-watcher.
const workspaceConfig = loadWorkspaceConfig(wsName);
const { child, heapMb, logPath, logFd } = startWorkspaceWatcher({
rootDir: ROOT,
workspaceName: wsName,
workspaceConfig,
spawnProcess: spawn,
nodeExecPath: process.execPath
});
child.on('error', err => {
console.error(`[Setup] Watcher spawn error: ${err.message}`);
Expand All @@ -1371,7 +1371,7 @@ route('POST', '/api/watcher/start', async (req, res) => {
console.error(`[Setup] Watcher for ${wsName} exited (code=${code}, signal=${signal})`);
});
child.unref();
console.log(`[Setup] Started watcher for ${wsName} (PID ${child.pid}, port ${wsConfig.port}, log: ${logPath})`);
console.log(`[Setup] Started watcher for ${wsName} (PID ${child.pid}, port ${wsConfig.port}, heap ${heapMb}MB, log: ${logPath})`);

res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true, pid: child.pid, workspace: wsName }));
Expand Down
34 changes: 34 additions & 0 deletions src/setup-watcher-start.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { join } from 'path';
import { openSync, closeSync } from 'fs';
import { buildWatcherNodeSpawnArgs } from './watcher/watcher-launch.js';

export function startWorkspaceWatcher({
rootDir,
workspaceName,
workspaceConfig,
spawnProcess,
nodeExecPath
}) {
const watcherScript = join(rootDir, 'src', 'watcher', 'watcher-client.js');
const { heapMb, args } = buildWatcherNodeSpawnArgs({
scriptPath: watcherScript,
workspaceName,
maxOldSpaceSizeMb: workspaceConfig?.watcher?.maxOldSpaceSizeMb
});

const logPath = join(rootDir, `watcher-${workspaceName}.log`);
const logFd = openSync(logPath, 'a');
let child;
try {
child = spawnProcess(nodeExecPath, args, {
cwd: rootDir,
detached: true,
stdio: ['ignore', logFd, logFd]
});
} catch (err) {
try { closeSync(logFd); } catch {}
throw err;
}

return { child, heapMb, logPath, logFd };
}
61 changes: 61 additions & 0 deletions src/setup-watcher-start.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, closeSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { startWorkspaceWatcher } from './setup-watcher-start.js';

describe('setup watcher startup heap args', () => {
const tempDirs = [];

afterEach(() => {
for (const dir of tempDirs.splice(0)) {
try { rmSync(dir, { recursive: true, force: true }); } catch {}
}
});

it('uses normalized default heap for non-finite values', () => {
const rootDir = mkdtempSync(join(tmpdir(), 'setup-watcher-start-'));
tempDirs.push(rootDir);
const calls = [];

const result = startWorkspaceWatcher({
rootDir,
workspaceName: 'discovery',
workspaceConfig: { watcher: { maxOldSpaceSizeMb: 'Infinity' } },
spawnProcess: (command, args, options) => {
calls.push({ command, args, options });
return { pid: 12345 };
},
nodeExecPath: process.execPath
});

assert.equal(calls.length, 1);
assert.ok(calls[0].args.includes('--max-old-space-size=8192'));
assert.equal(result.heapMb, 8192);
closeSync(result.logFd);
});

it('clamps negative heap values to minimum', () => {
const rootDir = mkdtempSync(join(tmpdir(), 'setup-watcher-start-'));
tempDirs.push(rootDir);
const calls = [];

const result = startWorkspaceWatcher({
rootDir,
workspaceName: 'discovery',
workspaceConfig: { watcher: { maxOldSpaceSizeMb: -1 } },
spawnProcess: (command, args, options) => {
calls.push({ command, args, options });
return { pid: 12345 };
},
nodeExecPath: process.execPath
});

assert.equal(calls.length, 1);
assert.ok(calls[0].args.includes('--max-old-space-size=512'));
assert.equal(result.heapMb, 512);
closeSync(result.logFd);
});
});

22 changes: 22 additions & 0 deletions src/watcher/heap-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const WATCHER_HEAP_DEFAULT_MB = 8192;
export const WATCHER_HEAP_MIN_MB = 512;
export const WATCHER_HEAP_MAX_MB = 32768;

/**
* Normalize heap size for Node --max-old-space-size.
* Ensures a finite integer and clamps to a safe range.
*/
export function normalizeWatcherHeapMb(rawValue, {
defaultMb = WATCHER_HEAP_DEFAULT_MB,
minMb = WATCHER_HEAP_MIN_MB,
maxMb = WATCHER_HEAP_MAX_MB
} = {}) {
const parsed = Number(rawValue);
if (!Number.isFinite(parsed)) return defaultMb;

const heapMb = Math.trunc(parsed);
if (heapMb < minMb) return minMb;
if (heapMb > maxMb) return maxMb;
return heapMb;
}

26 changes: 26 additions & 0 deletions src/watcher/heap-config.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
normalizeWatcherHeapMb,
WATCHER_HEAP_DEFAULT_MB,
WATCHER_HEAP_MIN_MB,
WATCHER_HEAP_MAX_MB
} from './heap-config.js';

test('normalizeWatcherHeapMb uses default for non-finite values', () => {
assert.equal(normalizeWatcherHeapMb(undefined), WATCHER_HEAP_DEFAULT_MB);
assert.equal(normalizeWatcherHeapMb('Infinity'), WATCHER_HEAP_DEFAULT_MB);
assert.equal(normalizeWatcherHeapMb(NaN), WATCHER_HEAP_DEFAULT_MB);
});

test('normalizeWatcherHeapMb clamps low and high values', () => {
assert.equal(normalizeWatcherHeapMb(-1), WATCHER_HEAP_MIN_MB);
assert.equal(normalizeWatcherHeapMb(1), WATCHER_HEAP_MIN_MB);
assert.equal(normalizeWatcherHeapMb(WATCHER_HEAP_MAX_MB + 1), WATCHER_HEAP_MAX_MB);
});

test('normalizeWatcherHeapMb returns integer value inside bounds', () => {
assert.equal(normalizeWatcherHeapMb('4096'), 4096);
assert.equal(normalizeWatcherHeapMb(2048.9), 2048);
});

17 changes: 17 additions & 0 deletions src/watcher/watcher-launch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { normalizeWatcherHeapMb } from './heap-config.js';

export function buildWatcherNodeSpawnArgs({ scriptPath, workspaceName = null, maxOldSpaceSizeMb }) {
const heapMb = normalizeWatcherHeapMb(maxOldSpaceSizeMb);
const args = [`--max-old-space-size=${heapMb}`, scriptPath];
if (workspaceName) args.push('--workspace', workspaceName);
return { heapMb, args };
}

export function buildWatcherCmdStartArgs({ scriptPath, title = 'Unreal Index Watcher', maxOldSpaceSizeMb }) {
const heapMb = normalizeWatcherHeapMb(maxOldSpaceSizeMb);
return {
heapMb,
args: ['/c', 'start', title, 'node', `--max-old-space-size=${heapMb}`, scriptPath]
};
}