Repository navigation
fix(config): recover a config.json that stays damaged (#692) #776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+2,139
−65
Merged
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
a9418fb
Recover and instrument corrupt config files
wonderwhy-er 707f579
Address corrupt config review feedback
wonderwhy-er 151bbd2
Harden corrupt config recovery
wonderwhy-er d8074c2
fix(config): a repair that can't be written leaves config.json for th…
mihailt f5e9b3d
fix(config): a repair recovers only the config's own fields, not a ne…
mihailt 02fff89
test(config): a config saved with a UTF-8 BOM must be read as it is (…
mihailt e316877
fix(config): read a config.json saved with a UTF-8 BOM (#692)
mihailt 96a989f
test(config): a damaged config.json must not block every start (#692)
mihailt d2fe94e
fix(config): a failed repair keeps file tools to the config folder (r…
mihailt 4a129aa
fix(config): a config.json missing under the lock is created with the…
mihailt c0b8b9c
fix(config): a config.json that can't be read keeps file tools to the…
mihailt ee29707
fix(config): a config.json read but not writable keeps its settings i…
mihailt 249a09d
fix(config): a config lock taken over while held no longer ends the p…
mihailt bfaf82c
fix(config): a value set_config_value couldn't save is in effect, as …
mihailt b8a5742
fix(config): a repair while running keeps every setting last read, te…
mihailt dcf8e43
fix(config): a write whose lock was lost is done over, not committed …
mihailt 2c5554b
fix(config): a value set_config_value couldn't save doesn't overwrite…
mihailt 19170de
test(config): the corrupt-config tests remove the temporary home they…
mihailt 5f1a50e
test(config): a damaged config.json keeps every setting still readabl…
mihailt 2002780
fix(config): a damaged config.json keeps every setting still readable…
mihailt 4252353
test(config): a read error, a removed file and a failed save keep the…
mihailt b96b67d
refactor(config): one recovery flow for a corrupt config.json
mihailt File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import fs from 'fs/promises'; | ||
| import path from 'path'; | ||
| import { existsSync } from 'fs'; | ||
| import type { ServerConfig } from './config-manager.js'; | ||
|
|
||
| /** Where a corrupt config.json was found: at start, by a write, or by the file watcher */ | ||
| export type RecoveryPhase = 'startup' | 'mutation' | 'watcher'; | ||
|
|
||
| /** The config_parse_error_recovered event */ | ||
| export interface RecoveryEvent { | ||
| phase: RecoveryPhase; | ||
| config_bytes: number | null; | ||
| backup_created: boolean; | ||
| recovered_by_other_process: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * The settings still readable in a corrupt config.json: the longest beginning | ||
| * of it that is a JSON object once closed, so every complete top-level setting | ||
| * before the first error. {} when there is none. | ||
| */ | ||
| export function salvageSettings(text: string): ServerConfig { | ||
| const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text; | ||
| // A readable beginning can end at each comma between top-level settings, or at | ||
| // the brace that closes the object (anything after it is ignored) | ||
| const ends: number[] = []; | ||
| let depth = 0; | ||
| let inString = false; | ||
| let escaped = false; | ||
| for (let i = 0; i < body.length; i++) { | ||
| const char = body[i]; | ||
| if (inString) { | ||
| if (escaped) escaped = false; | ||
| else if (char === '\\') escaped = true; | ||
| else if (char === '"') inString = false; | ||
| } else if (char === '"') { | ||
| inString = true; | ||
| } else if (char === '{' || char === '[') { | ||
| depth++; | ||
| } else if (char === '}' || char === ']') { | ||
| if (--depth === 0) { ends.push(i); break; } | ||
| } else if (char === ',' && depth === 1) { | ||
| ends.push(i); | ||
| } | ||
| } | ||
| for (const end of ends.reverse()) { | ||
| try { | ||
| return JSON.parse(body.slice(0, end) + '}'); | ||
| } catch { | ||
| // Not valid JSON up to here either, so try a shorter beginning | ||
| } | ||
| } | ||
| return {}; | ||
| } | ||
|
|
||
| /** | ||
| * What a corrupt config.json is recovered as: the defaults, then the settings | ||
| * last read (while running), then the settings salvaged from it. The welcome | ||
| * page stays off, as for any existing install. | ||
| */ | ||
| export function buildRecoveredConfig(defaults: ServerConfig, lastRead: ServerConfig | null, text: string): ServerConfig { | ||
| const { version: _version, ...settings } = lastRead ?? {}; | ||
| return { ...defaults, ...settings, ...salvageSettings(text), welcomeOnboardingEligible: false, pendingWelcomeOnboarding: false }; | ||
| } | ||
|
|
||
| /** | ||
| * Backs up a corrupt config.json as config.json.corrupt.<ms>.<pid>, unless the | ||
| * newest backup already holds `bytes` (a recovery tried again after its write | ||
| * failed). Returns the backup's name, or null when there is no config.json. | ||
| */ | ||
| export async function backupCorruptConfig(configPath: string, bytes: Buffer): Promise<string | null> { | ||
| if (!existsSync(configPath)) return null; | ||
| const folder = path.dirname(configPath); | ||
| const prefix = `${path.basename(configPath)}.corrupt.`; | ||
| const newest = (await fs.readdir(folder).catch(() => [] as string[])) | ||
| .filter((name) => name.startsWith(prefix)) | ||
| .sort((a, b) => parseInt(a.slice(prefix.length), 10) - parseInt(b.slice(prefix.length), 10)) | ||
| .pop(); | ||
| if (newest && (await fs.readFile(path.join(folder, newest)).catch(() => null))?.equals(bytes)) return newest; | ||
| const name = `${prefix}${Date.now()}.${process.pid}`; | ||
| await fs.copyFile(configPath, path.join(folder, name)); | ||
| return name; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import path from 'path'; | ||
| import { spawnSync } from 'child_process'; | ||
| import { fileURLToPath, pathToFileURL } from 'url'; | ||
|
|
||
| const DIST = pathToFileURL(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'dist')).href; | ||
|
|
||
| /** | ||
| * Loads the built config manager (dist/config-manager.js) in a child process | ||
| * of its own, with `env` (a test home), so a test can make the file system | ||
| * fail the way it needs before the config manager uses it. | ||
| * | ||
| * The script sees `fs` (fs/promises, the object the config manager calls: | ||
| * patch its methods in `prelude`), `fsSync` (fs), `DIST` (the dist/ folder's | ||
| * URL, for more imports) and, in `body`, `configManager`. `body` prints its | ||
| * result as the last stdout line, as JSON. | ||
| * Returns { status, result (that JSON, or undefined), stdout, stderr }. | ||
| */ | ||
| export function runConfigManagerChild(env, { prelude = '', body }) { | ||
| const script = ` | ||
| import fs from 'fs/promises'; | ||
| import fsSync from 'fs'; | ||
| const DIST = ${JSON.stringify(DIST)}; | ||
| ${prelude} | ||
| const { configManager } = await import(DIST + '/config-manager.js'); | ||
| ${body}`; | ||
| const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { env, encoding: 'utf8', timeout: 60_000 }); | ||
| const last = (child.stdout ?? '').trim().split('\n').pop(); | ||
| let result; | ||
| try { | ||
| result = JSON.parse(last); | ||
| } catch { | ||
| // No JSON result line: the caller reports status, stdout and stderr instead | ||
| } | ||
| return { status: child.status, result, stdout: child.stdout ?? '', stderr: child.stderr ?? '' }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import path from 'path'; | ||
| import { fileURLToPath } from 'url'; | ||
| import { Client } from '@modelcontextprotocol/sdk/client/index.js'; | ||
| import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; | ||
| import { LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk/types.js'; | ||
| import { closeClient } from './close-client.js'; | ||
|
|
||
| const SERVER = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'dist', 'index.js'); | ||
|
|
||
| /** | ||
| * Starts the built server (dist/index.js) the way `desktop-commander remote` | ||
| * starts its local MCP: client "desktop-commander-client", DC_REMOTE_DEVICE=true. | ||
| * Resolves once `initialize` has succeeded; rejects with the client's error | ||
| * otherwise (e.g. `MCP error -32603: …`, which `remote` reports as | ||
| * "Device startup failed"). | ||
| * | ||
| * The result collects what the server reports: `logs` (its console output, | ||
| * which reaches the client as log notifications: `{ level, data }`) and | ||
| * `stderr`. Call `close()` when done. | ||
| */ | ||
| export async function startServerLikeRemote(env, { timeout = 30_000 } = {}) { | ||
| const transport = new StdioClientTransport({ | ||
| command: process.execPath, | ||
| args: [SERVER], | ||
| env: { ...env, DC_REMOTE_DEVICE: 'true' }, | ||
| stderr: 'pipe', | ||
| }); | ||
| const client = new Client({ name: 'desktop-commander-client', version: '1.0.0' }, { capabilities: {} }); | ||
| const server = { | ||
| client, | ||
| logs: [], | ||
| stderr: '', | ||
| close: () => closeClient(client), | ||
| }; | ||
| transport.stderr?.on('data', (chunk) => { server.stderr += chunk; }); | ||
| client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { server.logs.push(notification.params); }); | ||
| try { | ||
| await client.connect(transport, { timeout }); | ||
| } catch (error) { | ||
| await server.close(); | ||
| throw error; | ||
| } | ||
| return server; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| // Repro (#692): with a damaged ~/.claude-server-commander/config.json, | ||
| // `desktop-commander remote` fails every start with `MCP error -32603`, and | ||
| // keeps failing until the user moves the file aside. | ||
| // | ||
| // Reports: macOS 0.2.50, a truncated file: "Unexpected end of JSON input". | ||
| // Windows 0.2.51 (#697 comments), no other instance running: | ||
| // "Unexpected token '', ""... is not valid JSON". Node quotes the file's first | ||
| // 10 characters there, all invisible: a file of NUL bytes, as a crash | ||
| // mid-write leaves it (a BOM would show the `{` after it). | ||
| // | ||
| // Mechanism: init() cannot parse the file (after retrying it for 1 s), logs it | ||
| // and carries on with in-memory defaults; the file stays as it is. Those | ||
| // defaults say "new install, welcome page pending", so `initialize` clears the | ||
| // flag with setValue, whose mutation reads the same file again and throws: the | ||
| // client gets -32603 and `remote` shuts the device down. Every client fails the | ||
| // same way, not only `remote`. | ||
| // | ||
| // Measured on 4715bd4: 10 of 10 starts failed on Windows 11 and 10 of 10 on | ||
| // macOS 26, each after ~3.4-3.8 s (the 1 s read wait, twice), for all five | ||
| // kinds of damage below; config.json was never changed. | ||
| // | ||
| // Here each kind of damage is written to config.json in the temporary home, | ||
| // and the current build is started the way `remote` starts its local MCP | ||
| // (client "desktop-commander-client", DC_REMOTE_DEVICE=true), then asked for | ||
| // get_config. After each start the file is compared with what was written. | ||
| // | ||
| // Run: node test/repro/run-repro.js test-config-damaged-start.js | ||
| // (REPRO_RUNS=2 starts per kind of damage by default) | ||
| // Exit code: 1 if any start failed. | ||
| import fs from 'fs'; | ||
| import os from 'os'; | ||
| import path from 'path'; | ||
| import { isTestHome } from '../helpers/test-env.js'; | ||
| import { startServerLikeRemote } from '../helpers/mcp-server.js'; | ||
| import { exitProcess } from '../../dist/utils/exit-process.js'; | ||
|
|
||
| const RUNS = Number(process.env.REPRO_RUNS || 2); | ||
|
|
||
| // This replaces config.json, so never in a real home | ||
| if (!isTestHome()) { | ||
| console.error('Run it through the repro runner: node test/repro/run-repro.js test-config-damaged-start.js'); | ||
| exitProcess(2); | ||
| } | ||
|
|
||
| const configDir = path.join(os.homedir(), '.claude-server-commander'); | ||
| const configPath = path.join(configDir, 'config.json'); | ||
|
|
||
| const saved = JSON.stringify({ | ||
| blockedCommands: ['rm'], | ||
| allowedDirectories: [os.tmpdir()], | ||
| telemetryEnabled: false, | ||
| pendingWelcomeOnboarding: false, | ||
| welcomeOnboardingEligible: false, | ||
| }, null, 2); | ||
|
|
||
| const DAMAGE = { | ||
| // The issue's own minimal reproduction | ||
| truncated: Buffer.from('{"defaultShell":'), | ||
| empty: Buffer.alloc(0), | ||
| // A crash mid-write can leave the file's length with no data behind it | ||
| nul: Buffer.alloc(saved.length), | ||
| // An editor saving it as "UTF-8 with BOM" (Notepad, PowerShell 5 Set-Content -Encoding UTF8) | ||
| bom: Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(saved)]), | ||
| // A hand edit adding a Windows folder without doubling its backslashes | ||
| handEdit: Buffer.from(saved.replace(JSON.stringify(os.tmpdir()), '"C:\\Users\\me\\projects"')), | ||
| }; | ||
|
|
||
| async function start() { | ||
| const started = Date.now(); | ||
| let server; | ||
| let error = ''; | ||
| try { | ||
| server = await startServerLikeRemote(process.env); | ||
| const result = await server.client.callTool({ name: 'get_config', arguments: {} }); | ||
| if (result.isError) error = `get_config: ${result.content?.[0]?.text}`; | ||
| } catch (e) { | ||
| error = e?.message ?? String(e); | ||
| } finally { | ||
| await server?.close(); | ||
| } | ||
| return { error, ms: Date.now() - started }; | ||
| } | ||
|
|
||
| async function runRepro() { | ||
| let failed = 0; | ||
| let total = 0; | ||
| for (const [kind, bytes] of Object.entries(DAMAGE)) { | ||
| fs.rmSync(configDir, { recursive: true, force: true }); | ||
| fs.mkdirSync(configDir, { recursive: true }); | ||
| fs.writeFileSync(configPath, bytes); | ||
| for (let run = 1; run <= RUNS; run++) { | ||
| total++; | ||
| const { error, ms } = await start(); | ||
| if (error) failed++; | ||
| const after = fs.existsSync(configPath) ? fs.readFileSync(configPath) : null; | ||
| const file = after === null ? 'gone' : after.equals(bytes) ? 'unchanged' : `rewritten (${after.length} bytes)`; | ||
| const others = fs.readdirSync(configDir).filter((name) => name !== 'config.json'); | ||
| console.log(`${kind} start ${run}: ${error ? `FAILED after ${ms} ms: ${JSON.stringify(error)}` : `ok in ${ms} ms`}; config.json ${file}${others.length ? `; also in the folder: ${others.join(', ')}` : ''}`); | ||
| } | ||
| } | ||
|
|
||
| console.log(failed > 0 | ||
| ? `REPRODUCED: ${failed} of ${total} starts with a damaged config.json failed` | ||
| : `NOT REPRODUCED: ${total} starts with a damaged config.json, none failed`); | ||
| exitProcess(failed > 0 ? 1 : 0); | ||
| } | ||
|
|
||
| // Only in a test home: outside one, the check above refused to run | ||
| if (isTestHome()) await runRepro(); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: wonderwhy-er/DesktopCommanderMCP
Length of output: 4237
🏁 Script executed:
Repository: wonderwhy-er/DesktopCommanderMCP
Length of output: 12697
Add a per-process sequence to backup names.
Each recovery reads the config under the lock before it calls
backupCorruptConfig. The lock serializes recoveries, but a later write can leave different damaged bytes for another recovery. If both recoveries run in the same millisecond, the helper selects the same name. Its equality check only reuses a backup when the newest backup has identical bytes.fs.copyFileoverwrites an existing destination by default, so the second copy can erase the first damaged snapshot.Declare
let backupSeq = 0;at module scope.Suggested fix
🤖 Prompt for AI Agents
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Leaving this as is. Recoveries run one at a time under the config lock. A collision needs two recoveries of different damaged contents in one process within the same millisecond, with the file damaged again in between. Even then
config.jsonitself is recovered correctly; only the older copy is replaced.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@mihailtThanks for clarifying the accepted trade-off. The collision can replace an older backup in that narrow timing case, but it does not affect recovery ofconfig.json. Leaving the implementation unchanged is reasonable.✏️ Learnings added
You are interacting with an AI system.