fix(mcp): serialize shared panel key repair and credit report

This commit is contained in:
Sameh Khamis committed 2026-10-07 15:12:44 -07:00
1 parent 7592718415
commit acda360cf0
4 files changed
+138 -17

No files matched your search

+7 -7
View File
@@ -59,13 +59,13 @@
## [Unreleased] ## [Unreleased]
- Keep the globe panel working when an MCP client starts more than one - Stdio servers from one install share a panel key so a page read from one
server process. Each stdio server made its own panel key, so a client that process can make requests through another. Concurrent malformed-key repairs
read the panel page from one process and sent its requests to another now select one winner; unavailable storage or a busy repair retains the
(seen in Claude Desktop) got "Only the God's Eye View panel may make this logged per-process fallback. Thanks to [Jibran Tahir](https://github.com/jibraaan)
request". Stdio servers of one install now share a key, created once in for #958, [MarvinNL046](https://github.com/MarvinNL046) for the report and
`.gev-cache/`; a server that cannot store it keeps a key of its own, as proposed diagnosis in #927, and [kvnloo](https://github.com/kvnloo) for identifying
before (#927). the repair race. Windows/Claude Desktop Cowork confirmation remains outstanding.
- Street Level: a street-level imagery layer modelled on the iD editor's photo - Street Level: a street-level imagery layer modelled on the iD editor's photo
overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0 overlay, with Mapillary as its first provider (free client token, CC BY-SA 4.0
+13
View File
@@ -14,6 +14,19 @@ new views from the page that frames it; framing is off unless
`GEV_EMBED_FRAME_ANCESTORS` allows the framing page. MCP leads with the tools that find `GEV_EMBED_FRAME_ANCESTORS` allows the framing page. MCP leads with the tools that find
what to show. See [tools and the MCP server](TOOLS.md). what to show. See [tools and the MCP server](TOOLS.md).
Stdio processes from one install share the panel key in
`.gev-cache/mcp-panel-key`, resolved from the checkout rather than the client's
working directory. First creation is atomic; malformed-file repairs select one
winner and concurrent processes re-read it. A busy repair waits at most two
seconds before falling back. If the cache cannot be read/written or repair remains
busy, stderr reports the fallback to a per-process key; cross-process panels can
still fail in that mode. An abandoned `.repair-lock` is left intact: after
confirming no repair process is running, remove it to allow repair on restart.
HTTP MCP servers retain per-server keys. This key is a panel routing guard, not
authentication. Large-response continuations remain process-local and require
consistent tool-call routing. Two-process reproduction is covered locally;
Windows/Claude Desktop Cowork confirmation remains outstanding (#927).
## Tools and local MCP server — October 1, 2026 ## Tools and local MCP server — October 1, 2026
`gods-eye-view/tools` defines queries that answer questions from the app's data, `gods-eye-view/tools` defines queries that answer questions from the app's data,
+42 -10
View File
@@ -20,17 +20,52 @@ function readKey(file) {
return KEY_PATTERN.test(text) ? text : null; return KEY_PATTERN.test(text) ? text : null;
} }
/** Serialize malformed-file repair; ordinary first creation still uses link(). */
function repairKey(file, tmp, key) {
const lock = `${file}.repair-lock`;
const deadline = Date.now() + 2000;
const pause = new Int32Array(new SharedArrayBuffer(4));
let fd;
for (;;) {
try {
fd = fs.openSync(lock, 'wx', 0o600);
break;
} catch (error) {
if (error.code !== 'EEXIST') throw error;
// Another repair may already have published a usable key.
const winner = readKey(file);
if (winner) return winner;
if (Date.now() >= deadline) {
throw Object.assign(new Error('panel key repair is busy'), {
code: 'ETIMEDOUT',
});
}
Atomics.wait(pause, 0, 0, 10);
}
}
try {
// A repair completed between our earlier read and acquiring the lock.
const winner = readKey(file);
if (winner) return winner;
fs.renameSync(tmp, file);
return key;
} finally {
fs.closeSync(fd);
fs.rmSync(lock, { force: true });
}
}
/** /**
* The panel key every stdio server of this install shares. * The panel key every stdio server of this install shares.
* *
* A client can start more than one server process for one connection: Claude * A client can start more than one server process for one connection: Claude
* Desktop has been seen serving the globe panel's page from one and sending * Desktop was reported to start two processes; serving the panel's page from
* the page's `panel_request` calls to the other. A key made per process then * one and sending its `panel_request` calls to the other reproduces #927.
* never matches, and the panel reports that only it may make the request * Per-process keys then mismatch, and the panel refuses the request.
* (#927). The first process to run creates the key, without replacing one * The first process to run creates the key, without replacing one
* another process created at the same moment; the rest read it. When the file * another process created at the same moment; the rest read it. When the file
* cannot be read or written, the process falls back to a key of its own, * cannot be read or written, or repair stays busy for two seconds, the process
* which is what every process used before. * falls back to its own key, as every process did before.
* *
* As before, the key keeps `panel_request` from clients that only list it; it * As before, the key keeps `panel_request` from clients that only list it; it
* is not access control. * is not access control.
@@ -71,10 +106,7 @@ export function sharedPanelKey({
if (error.code !== 'EEXIST') throw error; if (error.code !== 'EEXIST') throw error;
const winner = readKey(file); const winner = readKey(file);
if (winner) return winner; if (winner) return winner;
// A malformed file left by something else: replace it. return repairKey(file, tmp, key);
fs.renameSync(tmp, file);
tmp = null;
return key;
} }
} catch (error) { } catch (error) {
log(notShared(error)); log(notShared(error));
+76
View File
@@ -122,3 +122,79 @@ test('processes starting at the same moment agree on one key', async (t) => {
assert.equal(new Set(keys).size, 1); assert.equal(new Set(keys).size, 1);
assert.equal(fs.readFileSync(file, 'utf8').trim(), keys[0]); assert.equal(fs.readFileSync(file, 'utf8').trim(), keys[0]);
}); });
test('concurrent malformed-file repairs return the same persisted key', async (t) => {
const file = tempKeyFile(t);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'not a key\n');
const script = `
import fs from 'node:fs';
const { sharedPanelKey } = await import(${JSON.stringify(PANEL_KEY_MODULE)});
const [file, marker, other] = process.argv.slice(1);
const original = fs.readFileSync;
let reads = 0;
fs.readFileSync = function (target, ...args) {
const text = original.call(this, target, ...args);
if (target === file && ++reads === 2) {
// Both processes capture the actual malformed contents before either
// can replace them. The old unchecked rename returns two different keys.
fs.writeFileSync(marker, 'ready');
const deadline = Date.now() + 5000;
while (!fs.existsSync(other)) {
if (Date.now() >= deadline) throw new Error('repair barrier timed out');
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
}
}
return text;
};
process.stdout.write(sharedPanelKey({ file }));
`;
const run = (index) =>
new Promise((resolve, reject) => {
const marker = (id) => path.join(path.dirname(file), `ready-${id}`);
const child = spawn(
process.execPath,
[
'--input-type=module',
'-e',
script,
file,
marker(index),
marker(1 - index),
],
{ stdio: ['ignore', 'pipe', 'inherit'] },
);
t.after(() => child.kill());
let out = '';
child.stdout.on('data', (chunk) => (out += chunk));
child.on('error', reject);
child.on('close', (code) =>
code === 0 ? resolve(out) : reject(new Error(`exit ${code}`)),
);
});
const keys = await Promise.all([run(0), run(1)]);
assert.equal(new Set(keys).size, 1);
assert.equal(fs.readFileSync(file, 'utf8').trim(), keys[0]);
assert.equal(fs.existsSync(`${file}.repair-lock`), false);
assert.equal(
fs.readdirSync(path.dirname(file)).some((name) => name.endsWith('.tmp')),
false,
);
});
test('an unavailable repair lock falls back without hanging or removing its owner’s lock', (t) => {
const file = tempKeyFile(t);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, 'not a key\n');
fs.writeFileSync(`${file}.repair-lock`, 'held');
const logged = [];
const key = sharedPanelKey({ file, log: (line) => logged.push(line) });
assert.match(key, /^[A-Za-z0-9_-]{43}$/);
assert.match(logged[0], /ETIMEDOUT/);
assert.equal(fs.readFileSync(file, 'utf8'), 'not a key\n');
assert.equal(fs.readFileSync(`${file}.repair-lock`, 'utf8'), 'held');
assert.deepEqual(fs.readdirSync(path.dirname(file)).sort(), [
'mcp-panel-key',
'mcp-panel-key.repair-lock',
]);
});