@akiojin/unity-mcp-server 2.41.7 → 2.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { logger, config } from './config.js';
2
2
  import { JobManager } from './jobManager.js';
3
+ import { getWorkerPool } from './indexBuildWorkerPool.js';
3
4
 
4
5
  export class IndexWatcher {
5
6
  constructor(unityConnection) {
@@ -8,19 +9,32 @@ export class IndexWatcher {
8
9
  this.running = false;
9
10
  this.jobManager = JobManager.getInstance();
10
11
  this.currentWatcherJobId = null;
12
+ this.workerPool = getWorkerPool();
13
+ // FR-061: Use Worker Thread for watcher builds
14
+ this.useWorkerThread = true;
11
15
  }
12
16
 
13
17
  start() {
14
18
  if (!config.indexing?.watch) return;
15
19
  if (this.timer) return;
16
20
  const interval = Math.max(2000, Number(config.indexing.intervalMs || 15000));
17
- logger.info(`[index] watcher enabled (interval=${interval}ms)`);
18
- this.timer = setInterval(() => this.tick(), interval);
19
- if (typeof this.timer.unref === 'function') {
20
- this.timer.unref();
21
+ // Initial delay: wait longer to allow MCP server to fully initialize
22
+ // and first tool calls to complete before starting background indexing
23
+ const initialDelay = Math.max(30000, Number(config.indexing.initialDelayMs || 30000));
24
+ logger.info(`[index] watcher enabled (interval=${interval}ms, initialDelay=${initialDelay}ms)`);
25
+
26
+ // Delay initial tick significantly to avoid blocking MCP server initialization
27
+ const delayedStart = setTimeout(() => {
28
+ this.tick();
29
+ // Start periodic timer only after first tick
30
+ this.timer = setInterval(() => this.tick(), interval);
31
+ if (typeof this.timer.unref === 'function') {
32
+ this.timer.unref();
33
+ }
34
+ }, initialDelay);
35
+ if (typeof delayedStart.unref === 'function') {
36
+ delayedStart.unref();
21
37
  }
22
- // Initial kick
23
- this.tick();
24
38
  }
25
39
 
26
40
  stop() {
@@ -60,21 +74,10 @@ export class IndexWatcher {
60
74
  const jobId = `watcher-rebuild-${Date.now()}`;
61
75
  this.currentWatcherJobId = jobId;
62
76
 
63
- const { CodeIndexBuildToolHandler } = await import(
64
- '../handlers/script/CodeIndexBuildToolHandler.js'
65
- );
66
- const handler = new CodeIndexBuildToolHandler(this.unityConnection);
67
-
68
- this.jobManager.create(jobId, async job => {
69
- const params = {
70
- concurrency: config.indexing.concurrency || 8,
71
- retry: config.indexing.retry || 2,
72
- reportPercentage: 10
73
- };
74
- return await handler._executeBuild(params, job);
75
- });
76
-
77
- logger.info(`[index] watcher: started DB rebuild job ${jobId}`);
77
+ // FR-061: Use Worker Thread for non-blocking builds
78
+ await this._startWorkerBuild(jobId, info, dbPath);
79
+
80
+ logger.info(`[index] watcher: started DB rebuild job ${jobId} (Worker Thread)`);
78
81
  this._monitorJob(jobId);
79
82
  return;
80
83
  }
@@ -105,22 +108,10 @@ export class IndexWatcher {
105
108
  const jobId = `watcher-${Date.now()}`;
106
109
  this.currentWatcherJobId = jobId;
107
110
 
108
- const { CodeIndexBuildToolHandler } = await import(
109
- '../handlers/script/CodeIndexBuildToolHandler.js'
110
- );
111
- const handler = new CodeIndexBuildToolHandler(this.unityConnection);
112
-
113
- // Create the build job through JobManager
114
- this.jobManager.create(jobId, async job => {
115
- const params = {
116
- concurrency: config.indexing.concurrency || 8,
117
- retry: config.indexing.retry || 2,
118
- reportEvery: config.indexing.reportEvery || 500
119
- };
120
- return await handler._executeBuild(params, job);
121
- });
111
+ // FR-061: Use Worker Thread for non-blocking builds
112
+ await this._startWorkerBuild(jobId, info, dbPath);
122
113
 
123
- logger.info(`[index] watcher: started auto-build job ${jobId}`);
114
+ logger.info(`[index] watcher: started auto-build job ${jobId} (Worker Thread)`);
124
115
 
125
116
  // Monitor job completion in background
126
117
  // (Job result will be logged when it completes/fails)
@@ -132,6 +123,37 @@ export class IndexWatcher {
132
123
  }
133
124
  }
134
125
 
126
+ /**
127
+ * Start a build using Worker Thread pool
128
+ * @param {string} jobId - Job ID for tracking
129
+ * @param {Object} info - Project info
130
+ * @param {string} dbPath - Database file path
131
+ * @private
132
+ */
133
+ async _startWorkerBuild(jobId, info, dbPath) {
134
+ // Create job in JobManager
135
+ this.jobManager.create(jobId, async job => {
136
+ // Initialize progress
137
+ job.progress = { processed: 0, total: 0, rate: 0 };
138
+
139
+ // Subscribe to progress updates from Worker
140
+ this.workerPool.onProgress(progress => {
141
+ job.progress = progress;
142
+ });
143
+
144
+ // Execute build in Worker Thread (non-blocking)
145
+ const result = await this.workerPool.executeBuild({
146
+ projectRoot: info.projectRoot,
147
+ dbPath: dbPath,
148
+ concurrency: config.indexing?.watcherConcurrency || 1,
149
+ retry: config.indexing?.retry || 2,
150
+ reportPercentage: 10
151
+ });
152
+
153
+ return result;
154
+ });
155
+ }
156
+
135
157
  /**
136
158
  * Monitor job completion for logging
137
159
  * @private
@@ -124,7 +124,19 @@ export class HybridStdioServerTransport {
124
124
  }
125
125
 
126
126
  _readContentLengthMessage() {
127
- const headerEndIndex = this._buffer.indexOf(HEADER_END);
127
+ const { headerEndIndex, separatorLength } = (() => {
128
+ const crlfIndex = this._buffer.indexOf('\r\n\r\n');
129
+ const lfIndex = this._buffer.indexOf('\n\n');
130
+
131
+ if (crlfIndex === -1 && lfIndex === -1) return { headerEndIndex: -1, separatorLength: 0 };
132
+ if (crlfIndex === -1) return { headerEndIndex: lfIndex, separatorLength: 2 };
133
+ if (lfIndex === -1) return { headerEndIndex: crlfIndex, separatorLength: 4 };
134
+
135
+ return crlfIndex < lfIndex
136
+ ? { headerEndIndex: crlfIndex, separatorLength: 4 }
137
+ : { headerEndIndex: lfIndex, separatorLength: 2 };
138
+ })();
139
+
128
140
  if (headerEndIndex === -1) {
129
141
  return null;
130
142
  }
@@ -132,20 +144,20 @@ export class HybridStdioServerTransport {
132
144
  const header = this._buffer.toString('utf8', 0, headerEndIndex);
133
145
  const match = header.match(HEADER_RE);
134
146
  if (!match) {
135
- this._buffer = this._buffer.subarray(headerEndIndex + HEADER_END.length);
147
+ this._buffer = this._buffer.subarray(headerEndIndex + separatorLength);
136
148
  this.onerror?.(new Error('Invalid Content-Length header'));
137
149
  return null;
138
150
  }
139
151
 
140
152
  const length = Number(match[1]);
141
- const totalMessageLength = headerEndIndex + HEADER_END.length + length;
153
+ const totalMessageLength = headerEndIndex + separatorLength + length;
142
154
  if (this._buffer.length < totalMessageLength) {
143
155
  return null;
144
156
  }
145
157
 
146
158
  const json = this._buffer.toString(
147
159
  'utf8',
148
- headerEndIndex + HEADER_END.length,
160
+ headerEndIndex + separatorLength,
149
161
  totalMessageLength
150
162
  );
151
163
  this._buffer = this._buffer.subarray(totalMessageLength);
@@ -40,7 +40,8 @@ export class UnityConnection extends EventEmitter {
40
40
  }
41
41
 
42
42
  // Skip connection in CI/test environments
43
- if (process.env.NODE_ENV === 'test' || process.env.CI === 'true') {
43
+ const isTestEnv = process.env.NODE_ENV === 'test' || process.env.CI === 'true';
44
+ if (isTestEnv && process.env.UNITY_MCP_ALLOW_TEST_CONNECT !== '1') {
44
45
  logger.info('Skipping Unity connection in test/CI environment');
45
46
  reject(new Error('Unity connection disabled in test environment'));
46
47
  return;
@@ -50,6 +51,9 @@ export class UnityConnection extends EventEmitter {
50
51
  logger.info(`Connecting to Unity at ${targetHost}:${config.unity.port}...`);
51
52
 
52
53
  this.socket = new net.Socket();
54
+ try {
55
+ this.socket.setKeepAlive(true, 5000);
56
+ } catch {}
53
57
  let connectionTimeout = null;
54
58
  let resolved = false;
55
59
  const settle = (fn, value) => {
@@ -79,6 +83,11 @@ export class UnityConnection extends EventEmitter {
79
83
  settle(resolve);
80
84
  });
81
85
 
86
+ this.socket.on('end', () => {
87
+ // Treat end as close to trigger reconnection
88
+ this.socket.destroy();
89
+ });
90
+
82
91
  this.socket.on('data', data => {
83
92
  this.handleData(data);
84
93
  });
@@ -97,6 +106,11 @@ export class UnityConnection extends EventEmitter {
97
106
  this.socket.destroy();
98
107
  this.isDisconnecting = false;
99
108
  reject(error);
109
+ } else if (this.connected) {
110
+ // Force close to trigger reconnect logic
111
+ try {
112
+ this.socket.destroy();
113
+ } catch {}
100
114
  }
101
115
  });
102
116
 
@@ -210,6 +224,19 @@ export class UnityConnection extends EventEmitter {
210
224
  * @param {Buffer} data
211
225
  */
212
226
  handleData(data) {
227
+ // Fast-path: accept single unframed JSON message (NDJSON style) from tests/clients
228
+ if (!this.messageBuffer.length) {
229
+ const asString = data.toString('utf8').trim();
230
+ if (asString.startsWith('{')) {
231
+ try {
232
+ const obj = JSON.parse(asString);
233
+ this._processResponseObject(obj);
234
+ return;
235
+ } catch {
236
+ // fall through to framed parsing
237
+ }
238
+ }
239
+ }
213
240
  // Check if this is an unframed Unity debug log
214
241
  if (data.length > 0 && !this.messageBuffer.length) {
215
242
  const dataStr = data.toString('utf8');
@@ -281,56 +308,7 @@ export class UnityConnection extends EventEmitter {
281
308
  logger.debug(`[Unity] Received framed message (length=${message.length})`);
282
309
 
283
310
  const response = JSON.parse(message);
284
- logger.debug(
285
- `[Unity] Parsed response id=${response.id || 'n/a'} status=${response.status || (response.success === false ? 'error' : 'success')}`
286
- );
287
-
288
- // Check if this is a response to a pending command
289
- if (response.id && this.pendingCommands.has(response.id)) {
290
- logger.info(`[Unity] Found pending command for ID ${response.id}`);
291
- const pending = this.pendingCommands.get(response.id);
292
- this.pendingCommands.delete(response.id);
293
-
294
- // Handle both old and new response formats
295
- if (response.status === 'success' || response.success === true) {
296
- logger.info(`[Unity] Command ${response.id} succeeded`);
297
-
298
- let result = response.result || response.data || {};
299
-
300
- // If result is a string, try to parse it as JSON
301
- if (typeof result === 'string') {
302
- try {
303
- result = JSON.parse(result);
304
- logger.info(`[Unity] Parsed string result as JSON:`, result);
305
- } catch (parseError) {
306
- logger.warn(`[Unity] Failed to parse result as JSON: ${parseError.message}`);
307
- // Keep the original string value
308
- }
309
- }
310
-
311
- // Include version and editorState information if available
312
- if (response.version) {
313
- result._version = response.version;
314
- }
315
- if (response.editorState) {
316
- result._editorState = response.editorState;
317
- }
318
-
319
- logger.info(`[Unity] Command ${response.id} resolved successfully`);
320
- pending.resolve(result);
321
- } else if (response.status === 'error' || response.success === false) {
322
- logger.error(`[Unity] Command ${response.id} failed:`, response.error);
323
- pending.reject(new Error(response.error || 'Command failed'));
324
- } else {
325
- // Unknown format
326
- logger.warn(`[Unity] Command ${response.id} has unknown response format`);
327
- pending.resolve(response);
328
- }
329
- } else {
330
- // Handle unsolicited messages
331
- logger.debug(`[Unity] Received unsolicited message id=${response.id || 'n/a'}`);
332
- this.emit('message', response);
333
- }
311
+ this._processResponseObject(response);
334
312
  } catch (error) {
335
313
  logger.error('[Unity] Failed to parse response:', error.message);
336
314
  logger.debug(`[Unity] Raw message: ${messageData.toString().substring(0, 200)}...`);
@@ -438,6 +416,55 @@ export class UnityConnection extends EventEmitter {
438
416
  });
439
417
  }
440
418
 
419
+ _processResponseObject(response) {
420
+ logger.debug(
421
+ `[Unity] Parsed response id=${response?.id || 'n/a'} status=${response?.status || (response?.success === false ? 'error' : 'success')}`
422
+ );
423
+
424
+ const id = response?.id != null ? String(response.id) : null;
425
+
426
+ const hasExplicitPending = id && this.pendingCommands.has(id);
427
+ const fallbackPendingId =
428
+ !hasExplicitPending && this.pendingCommands.size > 0
429
+ ? this.pendingCommands.keys().next().value
430
+ : null;
431
+
432
+ if (hasExplicitPending || fallbackPendingId) {
433
+ const targetId = hasExplicitPending ? id : fallbackPendingId;
434
+ const pending = this.pendingCommands.get(targetId);
435
+ this.pendingCommands.delete(targetId);
436
+
437
+ if (response.status === 'success' || response.success === true) {
438
+ let result = response.result || response.data || {};
439
+ if (typeof result === 'string') {
440
+ try {
441
+ result = JSON.parse(result);
442
+ logger.info(`[Unity] Parsed string result as JSON:`, result);
443
+ } catch (parseError) {
444
+ logger.warn(`[Unity] Failed to parse result as JSON: ${parseError.message}`);
445
+ }
446
+ }
447
+ if (response.version) result._version = response.version;
448
+ if (response.editorState) result._editorState = response.editorState;
449
+ logger.info(`[Unity] Command ${targetId} resolved successfully`);
450
+ pending.resolve(result);
451
+ } else if (response.status === 'error' || response.success === false) {
452
+ logger.error(`[Unity] Command ${targetId} failed:`, response.error);
453
+ const err = new Error(response.error || 'Command failed');
454
+ err.code = response.code;
455
+ pending.reject(err);
456
+ } else {
457
+ logger.warn(`[Unity] Command ${targetId} has unknown response format`);
458
+ pending.resolve(response);
459
+ }
460
+ return;
461
+ }
462
+
463
+ // Unsolicited message
464
+ logger.debug(`[Unity] Received unsolicited message id=${response?.id || 'n/a'}`);
465
+ this.emit('message', response);
466
+ }
467
+
441
468
  /**
442
469
  * Sends a ping command to Unity
443
470
  * @returns {Promise<any>}