修复内置插件跨进程开关与不确定执行阻断

让 Runner 和 CLI 查询持久化开关并即时感知 GUI 状态更新
让隔离 MCP 经现有工具桥获取可用工具,并将开关纳入会话缓存标识
在执行入口复查禁用状态,固定原生函数缓存构建使用的工具快照
修复损坏开关文件保存成功后仍保持关闭的状态
保留插件执行结果不确定的结构化回执和宿主适配器阻断,拒绝并发积压与自动重发
新增跨进程工具目录、热切换、坏文件恢复及不确定执行回归测试并同步文档
This commit is contained in:
2026-09-10 21:37:51 +08:00
parent dd36de3aa6
commit 02ff914bd3
14 changed files with 564 additions and 47 deletions
+4
View File
@@ -29,6 +29,10 @@ native/cocos-editor-bridge/ 插件自带 native 模块(进程发现、pip
`execute``inject`,与 native 适配器的 `COCOS_EDITOR_RPC_METHODS` 一一对应;
`src/entry.test.mjs` 会校验两边不会漂移。
execute 不接受并发积压。结果不确定时返回 `needs-reconciliation`
`retryAllowed: false` 并阻止后续发送;native 适配器的阻断不会被 disconnect
或插件进程重载清除。请先核对编辑器状态,再重启客户端恢复。
## 项目上下文
插件从宿主获得当前受控项目路径:
@@ -84,6 +84,8 @@ impl AdapterRpcParams {
pub struct CocosEditorAdapter {
payload_candidates: Vec<PathBuf>,
connection: Mutex<Option<CocosEditorConnection>>,
// 独立于连接/插件进程生命周期,未知执行结果只能在人工核对后重启宿主恢复。
execution_uncertain: Mutex<bool>,
}
impl Default for CocosEditorAdapter {
@@ -97,6 +99,7 @@ impl CocosEditorAdapter {
Self {
payload_candidates,
connection: Mutex::new(None),
execution_uncertain: Mutex::new(false),
}
}
@@ -180,7 +183,8 @@ impl CocosEditorAdapter {
let code = params.code.clone().ok_or_else(|| "缺少 code".to_string())?;
validate_execute_code(&code).map_err(|error| error.to_string())?;
let timeout_ms = params.timeout_ms();
let response = match self.project_connection(&project_path)? {
let connection = self.project_connection(&project_path)?;
self.execute_with(|| match connection {
Some(connection) => execute_cocos_editor_code(
connection.process_id,
&connection.project_path,
@@ -188,9 +192,33 @@ impl CocosEditorAdapter {
timeout_ms,
),
None => execute_cocos_editor_code_for_project(&project_path, &code, timeout_ms),
})
}
fn execute_with(
&self,
execute: impl FnOnce() -> Result<crate::CocosEditorCommandResponse, crate::BridgeError>,
) -> Result<Value, String> {
let mut uncertain = self
.execution_uncertain
.lock()
.map_err(|_| "Cocos 执行状态不可用,执行结果需要核对".to_string())?;
if *uncertain {
return Ok(
json!({"ok": false, "status": "needs-reconciliation", "retryAllowed": false,
"error": "先前 Cocos execute 结果待核对,当前适配器不再发送执行命令"}),
);
}
// 持锁串行执行,后续调用必须先观察前一次是否产生不确定结果。
match execute() {
Ok(response) => serde_json::to_value(response).map_err(|error| error.to_string()),
Err(error) => {
*uncertain = matches!(&error, crate::BridgeError::ExecutionUncertain(_));
Ok(json!({"ok": false,
"status": if *uncertain { "needs-reconciliation" } else { "failed" },
"retryAllowed": !*uncertain, "error": error.to_string()}))
}
}
.map_err(|error| error.to_string())?;
serde_json::to_value(response).map_err(|error| error.to_string())
}
fn rpc_inject(&self, params: &AdapterRpcParams) -> Result<Value, String> {
@@ -347,6 +375,37 @@ impl EditorAdapter for CocosEditorAdapter {
mod tests {
use super::*;
#[test]
fn uncertain_execute_blocks_subsequent_dispatch_even_after_disconnect() {
let mut adapter = CocosEditorAdapter::default();
let result = adapter
.execute_with(|| {
Err(crate::BridgeError::ExecutionUncertain(
"timeout".to_string(),
))
})
.unwrap();
assert_eq!(result["status"], "needs-reconciliation");
assert_eq!(result["retryAllowed"], false);
adapter.disconnect();
let result = adapter
.execute_with(|| panic!("must not dispatch again"))
.unwrap();
assert_eq!(result["retryAllowed"], false);
}
#[test]
fn pre_dispatch_errors_do_not_latch_reconciliation() {
let adapter = CocosEditorAdapter::default();
for _ in 0..2 {
let result = adapter
.execute_with(|| Err(crate::BridgeError::TargetNotFound(42)))
.unwrap();
assert_eq!(result["status"], "failed");
assert_eq!(result["retryAllowed"], true);
}
}
#[test]
fn adapter_id_matches_plugin_manifest_adapter() {
let adapter = CocosEditorAdapter::default();
+37 -7
View File
@@ -40,6 +40,8 @@ export function createCocosEditorPlugin({
let nextId = 1;
let activeProjectPath = null;
let disposed = false;
let executionUncertain = false;
let executionPending = false;
const pending = new Map();
const handlers = new Map([
@@ -89,14 +91,41 @@ export function createCocosEditorPlugin({
async function handleExecute(params) {
const code = params?.code;
validateExecuteCode(code);
const response = await callEditor('execute', {
projectPath: resolveProjectPath(params),
code,
});
return {
status: response?.ok ? 'completed' : 'failed',
const projectPath = resolveProjectPath(params);
const reconcile = (response) => ({
status: 'needs-reconciliation',
retryAllowed: false,
response,
};
});
if (executionUncertain)
return reconcile({ ok: false, error: '先前执行结果待核对' });
// 不积压稍后执行的 mutation,避免调用方超时后请求仍从队列发出。
if (executionPending)
return {
status: 'failed',
retryAllowed: false,
response: {
ok: false,
error: '已有 Cocos execute 正在执行,请等待回执',
},
};
executionPending = true;
try {
const response = await callEditor('execute', { projectPath, code });
if (response?.status === 'needs-reconciliation') {
executionUncertain = true;
return reconcile(response);
}
if (typeof response?.ok !== 'boolean')
throw new Error('宿主缺少可信执行回执');
return { status: response.ok ? 'completed' : 'failed', response };
} catch (error) {
// 已交给宿主的 execute 超时/断线不能推断为未执行。
executionUncertain = true;
return reconcile({ ok: false, error: error.message });
} finally {
executionPending = false;
}
}
async function handleConnection(params) {
@@ -104,6 +133,7 @@ export function createCocosEditorPlugin({
if (!COCOS_EDITOR_OPERATIONS.includes(operation)) {
throw new Error(`不支持的能力操作:${operation}`);
}
if (operation === 'execute') return handleExecute(params);
if (operation === 'disconnect') {
return callEditor('disconnect', {});
}
+107 -1
View File
@@ -23,9 +23,10 @@ const pluginRoot = path.join(here, '..');
const tick = () => new Promise((resolve) => setImmediate(resolve));
function createHarness() {
function createHarness(timeoutMs) {
const outbound = [];
const plugin = createCocosEditorPlugin({
timeoutMs,
send: (message) => outbound.push(structuredClone(message)),
});
const respond = (id, result) =>
@@ -161,6 +162,111 @@ test('project.changed event updates the cached project path', async () => {
assert.equal(harness.plugin.activeProjectPath, null);
});
test('execute rejects concurrent requests and blocks later requests after uncertainty', async () => {
const harness = createHarness();
await startPlugin(harness);
const first = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 71,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
const second = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 72,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 2;' },
});
await tick();
const requests = harness.outbound.filter(
(item) => item.method === 'host.rpc',
);
assert.equal(requests.length, 1);
await harness.respond(requests[0].id, {
ok: false,
status: 'needs-reconciliation',
retryAllowed: false,
});
await Promise.all([first, second]);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 73,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 3;' },
});
assert.equal(
harness.outbound.filter((item) => item.method === 'host.rpc').length,
1,
);
assert.equal(
harness.outbound.find((item) => item.id === 72 && item.result).result
.retryAllowed,
false,
);
for (const id of [71, 73]) {
const reply = harness.outbound.find(
(item) => item.id === id && item.result,
);
assert.equal(reply.result.status, 'needs-reconciliation');
assert.equal(reply.result.retryAllowed, false);
}
});
test('host RPC failure blocks later execute without resending', async () => {
const harness = createHarness();
await startPlugin(harness);
const first = harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 81,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
await tick();
const rpc = harness.outbound.at(-1);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: rpc.id,
error: { message: 'connection closed' },
});
await first;
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 82,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 2;' },
});
assert.equal(
harness.outbound.filter((item) => item.method === 'host.rpc').length,
1,
);
assert.equal(harness.outbound.at(-1).result.retryAllowed, false);
});
test('execute timeout keeps later requests blocked even after a late success', async () => {
const harness = createHarness(100);
await startPlugin(harness);
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 91,
method: COCOS_EXECUTE_COMMAND_ID,
params: { code: 'return 1;' },
});
const rpc = harness.outbound.find((item) => item.method === 'host.rpc');
assert.equal(harness.outbound.at(-1).result.status, 'needs-reconciliation');
await harness.respond(rpc.id, { ok: true });
await harness.plugin.handleMessage({
jsonrpc: '2.0',
id: 92,
method: COCOS_CONNECTION_CAPABILITY_ID,
params: { operation: 'execute', code: 'return 2;' },
});
assert.equal(
harness.outbound.filter((item) => item.method === 'host.rpc').length,
1,
);
assert.equal(harness.outbound.at(-1).result.retryAllowed, false);
});
test('adapter request builder enforces per-operation parameters', () => {
assert.deepEqual(
buildEditorRpcRequest('status', {