diff --git a/src/core/README.md b/src/core/README.md index 7852b5dc..90d411d3 100644 --- a/src/core/README.md +++ b/src/core/README.md @@ -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)` 只读检查实例锁对应进程是否存活,沿用锁接管时的陈旧记录判定(开机时刻、可执行文件名),不获取或更改锁。 diff --git a/src/core/core.ts b/src/core/core.ts index 95cf4aad..69b83b1b 100644 --- a/src/core/core.ts +++ b/src/core/core.ts @@ -616,7 +616,7 @@ export class Core { 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, diff --git a/src/core/fs-busy.ts b/src/core/fs-busy.ts new file mode 100644 index 00000000..be8cc4b9 --- /dev/null +++ b/src/core/fs-busy.ts @@ -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(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(); +} diff --git a/src/core/state.ts b/src/core/state.ts index c105c564..005eef69 100644 --- a/src/core/state.ts +++ b/src/core/state.ts @@ -1,6 +1,7 @@ /** Core 机械状态,持久化到 `/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 */ @@ -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)); } /** diff --git a/src/core/timers.ts b/src/core/timers.ts index 5fbf6e60..07433428 100644 --- a/src/core/timers.ts +++ b/src/core/timers.ts @@ -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'; @@ -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)); } } diff --git a/src/protocol/open-responses/context-log.ts b/src/protocol/open-responses/context-log.ts index aa434e32..4992c670 100644 --- a/src/protocol/open-responses/context-log.ts +++ b/src/protocol/open-responses/context-log.ts @@ -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(value: T): T { @@ -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; } @@ -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)); } diff --git a/src/web/path-picker.ts b/src/web/path-picker.ts index 8ade56d2..1cec0cd7 100644 --- a/src/web/path-picker.ts +++ b/src/web/path-picker.ts @@ -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 @@ -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() diff --git a/src/worlds/minecraft/chests.ts b/src/worlds/minecraft/chests.ts index 65465ccb..d7be4efc 100644 --- a/src/worlds/minecraft/chests.ts +++ b/src/worlds/minecraft/chests.ts @@ -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', + '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(CHEST_BLOCKS); diff --git a/tests/core/core.test.ts b/tests/core/core.test.ts index 8d1c9c7c..d6a7cf04 100644 --- a/tests/core/core.test.ts +++ b/tests/core/core.test.ts @@ -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)); diff --git a/tests/core/state.test.ts b/tests/core/state.test.ts index 0c49fe92..f36a2918 100644 --- a/tests/core/state.test.ts +++ b/tests/core/state.test.ts @@ -136,3 +136,38 @@ describe('CoreState', () => { expect(s2.data.lastDeliveredCursor).toBe(172230); }); }); + +describe('CoreState 在 Windows 上遇到文件被短暂占用', () => { + let tmp: ReturnType; + 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'); + }); +});