@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
534 lines (533 loc) • 25.3 kB
JavaScript
import { createClient } from '@supabase/supabase-js';
import { captureRemote } from '../utils/capture.js';
const HEARTBEAT_INTERVAL = 15000;
// Cap a single channel recreate so a hung await can't pin the re-entrancy guard
// true (which would silently disable the connection watchdog).
const RECREATE_TIMEOUT_MS = 30000;
// Max time the channel may sit CONTINUOUSLY in 'joining' before we force a recreate.
// 'joining' is normally healthy (we let realtime-js's rejoin backoff converge), but on a
// HALF-OPEN socket (readyState OPEN yet dead) realtime-js parks the channel in 'joining'
// forever and never reconnects the socket — the device then wedges offline silently with
// no recreate firing. realtime-js's join push times out in ~10s, so a genuine join
// resolves/errors well within this window; 3 health ticks of unbroken 'joining' means the
// state machine has stalled and only a fresh socket (via recreate) recovers it.
const JOINING_WEDGE_TIMEOUT_MS = 30000;
export class RemoteChannel {
constructor() {
this.client = null;
this.channel = null;
this.heartbeatInterval = null;
this.connectionCheckInterval = null;
// Store subscription parameters for channel recreation
this.deviceId = null;
this.onToolCall = null;
// Track last device status to prevent duplicate log messages
this.lastDeviceStatus = 'offline';
// Track last channel state for debug logging
this.lastChannelState = null;
// Reconnect diagnostics + guard (see connState() / recreateChannel())
this.reconnectAttempt = 0; // recreateChannel() attempts since last success
this.isRecreatingChannel = false; // a recreate is in flight (re-entrancy guard)
this.joiningSince = null; // ts the channel entered an unbroken 'joining' run; null when not joining
this._user = null;
}
get user() { return this._user; }
initialize(url, key) {
this.client = createClient(url, key);
}
async setSession(session) {
if (!this.client)
throw new Error('Client not initialized');
console.debug('[DEBUG] RemoteChannel.setSession() called, has refresh_token:', !!session.refresh_token);
const { error } = await this.client.auth.setSession({
access_token: session.access_token,
refresh_token: session.refresh_token || ''
});
if (error) {
console.error('[DEBUG] Failed to set session:', error.message);
await captureRemote('remote_channel_set_session_error', { error });
return { error };
}
// Get user info
const { data: { user }, error: userError } = await this.client.auth.getUser();
if (userError) {
console.error('[DEBUG] Failed to get user:', userError.message);
await captureRemote('remote_channel_get_user_error', { error: userError });
throw userError;
}
if (!user) {
const noUserError = new Error('No user returned after setSession');
console.error('[DEBUG] No user returned:', noUserError.message);
await captureRemote('remote_channel_get_user_empty', {});
throw noUserError;
}
this._user = user;
console.debug('[DEBUG] Session set successfully, user:', user.email);
return { error };
}
async getSession() {
if (!this.client)
throw new Error('Client not initialized');
return await this.client.auth.getSession();
}
async findDevice(deviceId) {
if (!this.client)
throw new Error('Client not initialized');
const { data, error } = await this.client
.from('mcp_devices')
.select('id, device_name')
.eq('id', deviceId)
.eq('user_id', this.user?.id)
.maybeSingle();
if (error) {
console.error('[DEBUG] Failed to find device:', error.message);
await captureRemote('remote_channel_find_device_error', { error });
throw error;
}
return data;
}
async updateDevice(deviceId, updates) {
if (!this.client)
throw new Error('Client not initialized');
const { data, error } = await this.client
.from('mcp_devices')
.update(updates)
.eq('id', deviceId)
.select();
if (error) {
console.error('[DEBUG] Failed to update device:', error.message);
await captureRemote('remote_channel_update_device_error', { error });
}
else {
console.debug('[DEBUG] Device updated successfully');
}
return { data, error };
}
async createDevice(deviceData) {
if (!this.client)
throw new Error('Client not initialized');
const { data, error } = await this.client
.from('mcp_devices')
.insert(deviceData)
.select()
.single();
if (error) {
console.error('[DEBUG] Failed to create device:', error.message);
await captureRemote('remote_channel_create_device_error', { error });
throw error;
}
console.debug('[DEBUG] Device created successfully');
return { data, error };
}
async registerDevice(capabilities, currentDeviceId, deviceName, onToolCall) {
console.debug('[DEBUG] RemoteChannel.registerDevice() called, deviceId:', currentDeviceId);
let existingDevice = null;
if (currentDeviceId && this.user) {
console.debug('[DEBUG] Finding existing device...');
existingDevice = await this.findDevice(currentDeviceId);
console.debug('[DEBUG] Existing device found:', !!existingDevice);
}
if (existingDevice) {
console.debug('[DEBUG] Updating device status to online');
await this.updateDevice(existingDevice.id, {
status: 'online',
last_seen: new Date().toISOString(),
capabilities: {}, // TODO: Capabilities are not yet implemented; keep this empty object for schema compatibility until device capabilities are defined and stored.
device_name: deviceName
});
// Store parameters for channel recreation
this.deviceId = existingDevice.id;
this.onToolCall = onToolCall;
console.debug(`⏳ Subscribing to tool call channel...`);
// Create and subscribe to the channel
console.debug('[DEBUG] Calling createChannel()');
// ! Ignore silently in Initialization to reconnect after
await this.createChannel().catch((error) => {
console.debug(`[DEBUG] Failed to create channel, will retry after socket reconnect: ${error?.message || error} — ${this.connState()}`);
});
}
else {
console.error(` - ❌ Device not found: ${currentDeviceId}`);
await captureRemote('remote_channel_register_device_error', { error: 'Device not found', deviceId: currentDeviceId });
throw new Error(`Device not found: ${currentDeviceId}`);
}
}
/**
* Create and subscribe to the channel.
* This is used for both initial subscription and recreation after socket reconnects.
*/
createChannel() {
return new Promise((resolve, reject) => {
if (!this.client || !this.user?.id || !this.onToolCall) {
console.debug('[DEBUG] createChannel() failed - missing prerequisites');
return reject(new Error('Client not initialized or missing subscription parameters'));
}
console.debug('[DEBUG] Creating channel: device_tool_call_queue');
this.channel = this.client.channel('device_tool_call_queue')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'mcp_remote_calls',
filter: `user_id=eq.${this.user.id}`
}, (payload) => {
console.debug('[DEBUG] Realtime event received, payload:', payload?.new?.id);
if (this.onToolCall) {
this.onToolCall(payload);
}
})
.subscribe((status, err) => {
// Debug: Log all subscription status events
console.debug(`[DEBUG] Channel subscription status: ${status}${err ? ' (error: ' + (err?.message || err) + ')' : ''} — ${this.connState()}`);
if (status === 'SUBSCRIBED') {
const recovered = this.reconnectAttempt;
this.reconnectAttempt = 0;
console.log(`✅ Channel subscribed${recovered > 0 ? ` (recovered after ${recovered} attempt${recovered === 1 ? '' : 's'})` : ''}`);
// Update device status on successful connection
if (this.deviceId) {
this.setOnlineStatus(this.deviceId, 'online').catch(e => {
console.error('Failed to set online status:', e.message);
});
}
resolve();
}
else if (status === 'CHANNEL_ERROR') {
// CHANNEL_ERROR is the only status carrying a real error message.
console.error(`❌ Channel error: ${err?.message || 'unknown'} — ${this.connState()}`);
this.setOnlineStatus(this.deviceId, 'offline');
captureRemote('remote_channel_subscription_error', { error: err?.message || 'Channel error' }).catch(() => { });
reject(err || new Error('Failed to initialize tool call channel subscription'));
}
else if (status === 'TIMED_OUT') {
console.error(`⏱️ Channel subscription timed out, Reconnecting... — ${this.connState()}`);
this.setOnlineStatus(this.deviceId, 'offline');
captureRemote('remote_channel_subscription_timeout', { attempt: this.reconnectAttempt }).catch(() => { });
reject(new Error('Tool call channel subscription timed out'));
}
else if (status === 'CLOSED') {
// Settle the promise so an in-flight recreateChannel() can't await
// forever (which would wedge the re-entrancy guard / watchdog), and
// mark the device offline like the other degraded states.
console.warn(`⚠️ Channel closed — ${this.connState()}`);
this.setOnlineStatus(this.deviceId, 'offline');
reject(new Error('Tool call channel closed during subscribe'));
}
});
});
}
/**
* Compact connection state for logs — e.g. "socket=open(1) ch=errored attempt=3".
* readyState 1=OPEN (a 1 while joins keep failing = a half-open socket being reused),
* 3=CLOSED, '-'=no socket. Reads realtime-js internals defensively; never throws.
*/
connState() {
let socket = '?';
try {
const rt = this.client?.realtime;
socket = `${rt?.connectionState?.() ?? '?'}(${rt?.conn?.readyState ?? '-'})`;
}
catch { /* best effort */ }
return `socket=${socket} ch=${this.channel?.state ?? '-'} attempt=${this.reconnectAttempt}`;
}
/**
* Check if channel is connected, recreate if not.
*/
checkConnectionHealth() {
if (!this.channel || !this.client || !this.user?.id || !this.onToolCall) {
return;
}
const state = this.channel.state;
// Debug: Log current channel state (only if changed)
if (!this.lastChannelState || this.lastChannelState !== state) {
console.debug(`[DEBUG] channel state: ${state} — ${this.connState()}`);
this.lastChannelState = state;
}
// 'joined' = healthy. Clear the joining-overstay timer.
if (state === 'joined') {
this.joiningSince = null;
return;
}
// 'joining' = transitional — normally let realtime-js's own rejoin backoff converge
// instead of tearing the channel down mid-join (recreating on every non-joined state
// amputates that backoff). BUT bound it: on a half-open socket realtime-js can park
// the channel in 'joining' indefinitely without ever reconnecting the socket, so the
// recreate below would never fire and the device wedges offline silently. If 'joining'
// overstays JOINING_WEDGE_TIMEOUT_MS unbroken, force a recreate — the only path that
// disconnect()s the dead socket. (connState() in the log shows the half-open socket.)
if (state === 'joining') {
const now = Date.now();
if (this.joiningSince === null)
this.joiningSince = now;
const stuckMs = now - this.joiningSince;
if (stuckMs < JOINING_WEDGE_TIMEOUT_MS)
return;
console.debug(`[DEBUG] ⚠️ Channel stuck 'joining' ${Math.round(stuckMs / 1000)}s - forcing recreate — ${this.connState()}`);
captureRemote('remote_channel_joining_wedge', { stuckMs, attempt: this.reconnectAttempt });
this.joiningSince = null;
this.recreateChannel();
return;
}
// Unhealthy: closed, errored, leaving — recreate
this.joiningSince = null;
captureRemote('remote_channel_state_health', { state, attempt: this.reconnectAttempt });
console.debug(`[DEBUG] ⚠️ Channel in unhealthy state '${state}' - recreating... — ${this.connState()}`);
this.recreateChannel();
}
/**
* Run an async op but reject if it doesn't settle within `ms`, so a hung await
* can't leave isRecreatingChannel stuck true and disable the watchdog. Mirrors
* closeWithTimeout() in desktop-commander-integration.ts.
*/
async withTimeout(op, ms, name) {
let timer;
try {
return await Promise.race([
op(),
new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${name} timed out after ${ms}ms`)), ms);
}),
]);
}
finally {
if (timer)
clearTimeout(timer);
}
}
/**
* Recreate the channel by destroying old one and creating fresh instance.
*/
async recreateChannel() {
if (!this.client || !this.user?.id || !this.onToolCall) {
console.warn('Cannot recreate channel - missing parameters');
console.debug('[DEBUG] recreateChannel() aborted - missing prerequisites');
return;
}
// FIX: re-entrancy guard so a 10s health tick can't stack a second recreate
// on top of an in-flight one.
if (this.isRecreatingChannel) {
console.debug('[DEBUG] recreateChannel() skipped - already in progress');
return;
}
this.isRecreatingChannel = true;
this.reconnectAttempt++;
// Create fresh channel
console.log(`🔄 Recreating channel... (attempt ${this.reconnectAttempt}) — ${this.connState()}`);
try {
// Cap the whole recreate: a never-settling await (e.g. a subscribe that only
// ever emits CLOSED) must not pin isRecreatingChannel=true and silently disable
// the 10s watchdog. On timeout we reject -> catch -> finally clears the guard.
await this.withTimeout(async () => {
// Destroy old channel — AWAIT it so the channel registry empties before we
// rebuild. (The un-awaited version raced the synchronous new-channel push, so
// realtime-js never tore the socket down and a half-open one got reused.)
if (this.channel) {
console.debug('[DEBUG] Destroying old channel');
await this.client.removeChannel(this.channel);
this.channel = null;
}
// FIX (core): force a brand-new WebSocket. After idle / wifi-loss the socket can
// be HALF-OPEN (readyState OPEN but dead); reusing it made every join TIME_OUT
// forever. disconnect() drops it so the next subscribe() dials a fresh one.
try {
await this.client.realtime?.disconnect?.();
}
catch { /* best effort */ }
console.debug('[DEBUG] Calling createChannel() for recreation');
await this.createChannel();
}, RECREATE_TIMEOUT_MS, 'recreateChannel');
}
catch (err) {
captureRemote('remote_channel_recreate_error', { errMsg: err?.message, attempt: this.reconnectAttempt });
console.debug(`[DEBUG] Channel recreation failed: ${err?.message} — ${this.connState()}`);
}
finally {
this.isRecreatingChannel = false;
}
}
async markCallExecuting(callId) {
if (!this.client)
throw new Error('Client not initialized');
const { error } = await this.client
.from('mcp_remote_calls')
.update({ status: 'executing' })
.eq('id', callId);
if (error) {
console.error('[DEBUG] Failed to mark call executing:', error.message);
await captureRemote('remote_channel_mark_call_executing_error', { error });
}
else {
console.debug('[DEBUG] Call marked executing:', callId);
}
}
async updateCallResult(callId, status, result = null, errorMessage = null) {
if (!this.client)
throw new Error('Client not initialized');
const updateData = {
status: status,
completed_at: new Date().toISOString()
};
if (result !== null)
updateData.result = result;
if (errorMessage !== null)
updateData.error_message = errorMessage;
console.debug('[DEBUG] Updating call result:', updateData);
const { data, error } = await this.client
.from('mcp_remote_calls')
.update(updateData)
.eq('id', callId);
if (error) {
console.error('[DEBUG] Failed to update call result:', error.message);
await captureRemote('remote_channel_update_call_result_error', { error });
}
else {
console.debug('[DEBUG] Call result updated successfully:', data);
}
}
async updateHeartbeat(deviceId) {
if (!this.client)
return;
try {
const { error } = await this.client
.from('mcp_devices')
.update({ last_seen: new Date().toISOString() })
.eq('id', deviceId);
if (error) {
console.error('[DEBUG] Heartbeat update failed:', error.message);
await captureRemote('remote_channel_heartbeat_error', { error });
}
// console.log(`🔌 Heartbeat sent for device: ${deviceId}`);
}
catch (error) {
console.error('Heartbeat failed:', error.message);
await captureRemote('remote_channel_heartbeat_error', { error });
}
}
startHeartbeat(deviceId) {
console.debug('[DEBUG] Starting heartbeat for device:', deviceId);
this.connectionCheckInterval = setInterval(() => {
this.checkConnectionHealth();
}, 10000);
// Update last_seen every 15 seconds
this.heartbeatInterval = setInterval(async () => {
await this.updateHeartbeat(deviceId);
}, HEARTBEAT_INTERVAL);
console.debug('[DEBUG] Heartbeat intervals set - connectionCheck: 10s, heartbeat: 15s');
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.connectionCheckInterval) {
clearInterval(this.connectionCheckInterval);
this.connectionCheckInterval = null;
}
}
async setOnlineStatus(deviceId, status) {
if (!this.client)
return;
// Only log if status changed
if (this.lastDeviceStatus !== status) {
console.log(`🔌 Device marked as ${status}`);
this.lastDeviceStatus = status;
}
const { error } = await this.client
.from('mcp_devices')
.update({ status: status, last_seen: new Date().toISOString() })
.eq('id', deviceId);
if (error) {
console.error(`[DEBUG] Failed to set status ${status}:`, error.message);
if (status == "online") {
console.error('Failed to update device status:', error.message);
}
await captureRemote('remote_channel_status_update_error', { error, status });
return;
}
else {
console.debug(`[DEBUG] Device status set to ${status}`);
}
// console.log(status === 'online' ? `🔌 Device marked as ${status}` : `❌ Device marked as ${status}`);
}
async setOffline(deviceId) {
if (!deviceId || !this.client) {
console.debug('[DEBUG] setOffline() skipped - no deviceId or client');
return;
}
console.debug('[DEBUG] setOffline() initiating blocking update for device:', deviceId);
try {
// Get current session for the subprocess
const { data: sessionData } = await this.client.auth.getSession();
if (!sessionData?.session?.access_token) {
console.error('❌ No valid session for offline update');
console.debug('[DEBUG] Session data missing or invalid');
return;
}
// Get Supabase config from client
const supabaseUrl = this.client.supabaseUrl;
const supabaseKey = this.client.supabaseKey;
if (!supabaseUrl || !supabaseKey) {
console.error('❌ Missing Supabase configuration');
console.debug('[DEBUG] supabaseUrl or supabaseKey is missing');
return;
}
// Use spawnSync to run the blocking update script
const { spawnSync } = await import('child_process');
const { fileURLToPath } = await import('url');
const path = await import('path');
// Get the script path relative to this file
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const scriptPath = path.join(__dirname, 'scripts', 'blocking-offline-update.js');
console.debug('[DEBUG] Spawning blocking update script:', scriptPath);
console.debug('[DEBUG] Using node executable:', process.execPath);
const result = spawnSync('node', [
scriptPath,
deviceId,
supabaseUrl,
supabaseKey,
sessionData.session.access_token,
sessionData.session.refresh_token || ''
], {
timeout: 3000,
stdio: 'pipe', // Capture output to prevent blocking
encoding: 'utf-8'
});
console.debug('[DEBUG] spawnSync completed, exit code:', result.status, 'signal:', result.signal);
// Log subprocess output (with encoding:'utf-8', these are already strings)
if (result.stdout && result.stdout.trim()) {
console.log(result.stdout.trim());
}
if (result.stderr && result.stderr.trim()) {
console.error(result.stderr.trim());
}
// Handle exit codes
if (result.error) {
console.error('❌ Failed to spawn update process:', result.error.message);
console.debug('[DEBUG] spawn error:', result.error);
}
else if (result.status === 0) {
console.log('✓ Device marked as offline (blocking)');
}
else if (result.status === 2) {
console.warn('⚠️ Device offline update timed out');
}
else if (result.signal) {
console.error(`❌ Update process killed by signal: ${result.signal}`);
}
else {
console.error(`❌ Update process failed with exit code: ${result.status}`);
}
}
catch (error) {
console.error('❌ Error in blocking offline update:', error.message);
console.debug('[DEBUG] setOffline() error stack:', error.stack);
await captureRemote('remote_channel_offline_update_error', { error });
}
}
async unsubscribe() {
if (this.channel) {
await this.channel.unsubscribe();
this.channel = null;
console.log('✓ Unsubscribed from tool call channel');
}
}
}