修复内置插件跨进程开关与不确定执行阻断
让 Runner 和 CLI 查询持久化开关并即时感知 GUI 状态更新 让隔离 MCP 经现有工具桥获取可用工具,并将开关纳入会话缓存标识 在执行入口复查禁用状态,固定原生函数缓存构建使用的工具快照 修复损坏开关文件保存成功后仍保持关闭的状态 保留插件执行结果不确定的结构化回执和宿主适配器阻断,拒绝并发积压与自动重发 新增跨进程工具目录、热切换、坏文件恢复及不确定执行回归测试并同步文档
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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', {});
|
||||
}
|
||||
|
||||
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user