Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ handler 抛错,或返回值既不是字符串也不是带字符串 text 的对
装配层将 World 构造失败限制在该 World。`start()` 抛错时不挂载;`stop()` 失败或超时会记录
结果并继续卸载。`onOpening`、`onDelivery`、`onBatchEnd`、`onTurnEnded`、`onIdle` 异常记录
warn;`onHandoff` 异常记录 error 并使用默认交接策略。其他钩子的异常由调用方处理。
读取 `webUi()` 失败时省略该 World 的环境段,继续构建前缀。主循环异常退出时记录 error
并停止定时器。启动器处理未捕获的进程异常并执行关机流程。
读取 `webUi()` 失败时省略该 World 的环境段,继续构建前缀。主循环异常退出时记录 event 为 `loop-exit` 的 error
并停止定时器,进程不退出;宿主按这个 event 决定是否重启。启动器处理未捕获的进程异常并执行关机流程。

`instanceIsRunning(dataDir)` 只读检查实例锁对应进程是否存活,沿用锁接管时的陈旧记录判定(开机时刻、可执行文件名),不获取或更改锁。
2 changes: 1 addition & 1 deletion src/core/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,7 +616,7 @@ export class Core<C extends CoreConfig = CoreConfig> {
this.timers.start();
this.runPromise = this.loop.run().catch((e) => {
this.timers.stop();
this.log.error('主循环异常退出', { err: e });
this.log.emit('error', '主循环异常退出', { event: 'loop-exit', err: e });
});
writeRunJson(this.run, {
bot: this.loaded.config.displayName,
Expand Down
20 changes: 20 additions & 0 deletions src/core/fs-busy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Windows 上杀毒软件、索引器、同步盘会在文件刚写完时短暂独占打开它,这时 rename 与 open
* 报 EBUSY、EPERM 或 EACCES。做法同 graceful-fs:只在 Windows 上对这三种错误退避重试,
* 这里是同步调用,总共等约半秒;仍失败就照原样抛出。其他平台与其他错误立即抛出。
*/
const BUSY_CODES = new Set(['EBUSY', 'EPERM', 'EACCES']);
const BACKOFF_MS = [10, 30, 60, 120, 250];
const sleeper = new Int32Array(new SharedArrayBuffer(4));

export function retryBusy<T>(op: () => T): T {
for (const ms of BACKOFF_MS) {
try {
return op();
} catch (e) {
if (process.platform !== 'win32' || !BUSY_CODES.has((e as NodeJS.ErrnoException).code ?? '')) throw e;
Atomics.wait(sleeper, 0, 0, ms);
}
}
return op();
}
3 changes: 2 additions & 1 deletion src/core/state.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/** Core 机械状态,持久化到 `<dataDir>/core-state.json`。 */
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { retryBusy } from './fs-busy.ts';

export interface CoreStateData {
/** 上次截断时刻ISO */
Expand Down Expand Up @@ -84,7 +85,7 @@ export class CoreState {
const tmp = this.file + '.tmp';
writeFileSync(tmp, JSON.stringify(this.data, null, 2), 'utf8');
// 通过 rename 原子覆盖状态文件,不先删除,避免崩溃后状态缺失。Windows 的 renameSync 使用 MoveFileExW 与 MOVEFILE_REPLACE_EXISTING 支持覆盖。
renameSync(tmp, this.file);
retryBusy(() => renameSync(tmp, this.file));
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/core/timers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*/
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { retryBusy } from './fs-busy.ts';
import type { Logger, TimerEntry, TimersApi } from './types.ts';
import { nullLogger, shortId } from './util.ts';

Expand Down Expand Up @@ -124,6 +125,6 @@ export class TimerStore implements TimersApi {
const tmp = this.file + '.tmp';
writeFileSync(tmp, JSON.stringify(this.entries, null, 2), 'utf8');
// 通过 rename 原子覆盖,不先删除,避免崩溃后定时器全部丢失。
renameSync(tmp, this.file);
retryBusy(() => renameSync(tmp, this.file));
}
}
5 changes: 3 additions & 2 deletions src/protocol/open-responses/context-log.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { appendFileSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, truncateSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { retryBusy } from '../../core/fs-busy.ts';
import type { ContextRecord } from './context.ts';

function freeze<T>(value: T): T {
Expand Down Expand Up @@ -64,7 +65,7 @@ export class ContextLog {
append(entry: ContextRecord): ContextRecord {
const next = structuredClone(entry);
if (this.stamp && next.context.ts === undefined) next.context.ts = this.stamp();
appendFileSync(this.file, JSON.stringify(next) + '\n');
retryBusy(() => appendFileSync(this.file, JSON.stringify(next) + '\n'));
this.entries = Object.freeze([...this.entries, freeze(next)]);
return next;
}
Expand All @@ -83,5 +84,5 @@ function atomicContextWrite(path: string, records: readonly ContextRecord[]): vo
writeFileSync(fd, records.map(row => JSON.stringify(row)).join('\n') + (records.length ? '\n' : ''));
fsyncSync(fd);
} finally { closeSync(fd); }
renameSync(temporary, path);
retryBusy(() => renameSync(temporary, path));
}
6 changes: 3 additions & 3 deletions src/web/path-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ function titleOf(options: PathPickerOptions, t: Text): string {
}

const WINDOWS_SCRIPT = String.raw`
[WebUI]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
Add-Type -AssemblyName System.Windows.Forms
$title = $env:CORTICO_PICKER_TITLE
$initial = $env:CORTICO_PICKER_INITIAL
Expand All @@ -179,9 +179,9 @@ if ($env:CORTICO_PICKER_KIND -eq 'directory') {
}
if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
if ($env:CORTICO_PICKER_KIND -eq 'directory') {
[WebUI]::Out.Write($dialog.SelectedPath)
[Console]::Out.Write($dialog.SelectedPath)
} else {
[WebUI]::Out.Write($dialog.FileName)
[Console]::Out.Write($dialog.FileName)
}
}
$dialog.Dispose()
Expand Down
8 changes: 7 additions & 1 deletion src/worlds/minecraft/chests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@ import type { ItemStack } from './terrain.ts';
export const FURNACE_BLOCKS = ['furnace', 'smoker', 'blast_furnace'] as const;

/** 箱子族方块名(能当仓库用的那些);工作台、炉子族、酿造台都不在内 */
export const CHEST_BLOCKS = ['chest', 'trapped_chest', 'barrel', 'ender_chest'] as const;
export const CHEST_BLOCKS = [
'chest', 'trapped_chest', 'barrel', 'ender_chest',
'shulker_box', 'white_shulker_box', 'orange_shulker_box', 'magenta_shulker_box', 'light_blue_shulker_box',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这两处 bug 修复的 diff 里没有能在修复前失败的测试。

CONTRIBUTING.md「What is accepted」要求:Bug fixes in the runtime, the WebUI, the extension loader and the bundled Worlds and providers, with a test that fails before the fix.

  • src/worlds/minecraft/chests.ts 第 15 行:17 种潜影盒进入 CHEST_BLOCKS(chestsIn / CONTAINER_FIND / take 都读这份名单)。tests/worlds/minecraft/chests.test.ts 已有「chestsIn 只回箱子族」用例,本 diff 没给潜影盒加上对应断言。
  • src/web/path-picker.ts 第 160、182、184 行:[WebUI]:: 改回 [Console]::。diff 里没有覆盖 WINDOWS_SCRIPT 的测试。

请补上修复前会失败的测试,或把不测的理由写进 PR 说明。

— Cortico PR Gate via Cursor Cloud Agent

'yellow_shulker_box', 'lime_shulker_box', 'pink_shulker_box', 'gray_shulker_box', 'light_gray_shulker_box',
'cyan_shulker_box', 'purple_shulker_box', 'blue_shulker_box', 'brown_shulker_box', 'green_shulker_box',
'red_shulker_box', 'black_shulker_box',
] as const;

const CHEST_KINDS = new Set<string>(CHEST_BLOCKS);

Expand Down
2 changes: 1 addition & 1 deletion tests/core/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ describe('Core · session 声明与 fork 原语', () => {
const { core, llm } = build();
const exits: LogRecord[] = [];
core.runlog.onWrite((entry) => {
if (entry.msg === '主循环异常退出') exits.push(entry);
if (entry.event === 'loop-exit') exits.push(entry);
});
const due: string[] = [];
core.timers.onDue((entry) => due.push(entry.id));
Expand Down
35 changes: 35 additions & 0 deletions tests/core/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,38 @@ describe('CoreState', () => {
expect(s2.data.lastDeliveredCursor).toBe(172230);
});
});

describe('CoreState 在 Windows 上遇到文件被短暂占用', () => {
let tmp: ReturnType<typeof makeTmpDir>;
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
beforeEach(() => {
tmp = makeTmpDir();
Object.defineProperty(process, 'platform', { value: 'win32' });
});
afterEach(() => {
Object.defineProperty(process, 'platform', platform);
fsHooks.onRename = null;
tmp.cleanup();
});

const busy = () => Object.assign(new Error('EBUSY: resource busy or locked'), { code: 'EBUSY' });

it('占用几十毫秒后放开时 save 照常落盘', () => {
const s = new CoreState(tmp.dir);
s.load();
let left = 2;
fsHooks.onRename = () => { if (left-- > 0) throw busy(); };
s.data.lastDeliveredCursor = 42;
s.save();
const s2 = new CoreState(tmp.dir);
s2.load();
expect(s2.data.lastDeliveredCursor).toBe(42);
});

it('一直被占用时 save 照原样抛出', () => {
const s = new CoreState(tmp.dir);
s.load();
fsHooks.onRename = () => { throw busy(); };
expect(() => s.save()).toThrow('EBUSY');
});
});
Loading