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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,11 @@ If `XDG_DATA_HOME` is not set, the default is:

Set `OPENCODE_GOAL_STATE_PATH` to use a custom file.

The state file is written atomically with owner-only permissions when the host filesystem supports it. Existing active goals recover from disk with their full objective, budget, history, and checkpoint metadata.
The state file is written atomically through a same-directory temp file: the final path is only ever replaced by a fully-flushed file, so after a crash the state is the previous or the new valid version, never a torn one. The file is created with owner-only permissions where the host filesystem supports them, and the temp name is a random UUID opened exclusively so concurrent writers cannot collide.

Ordinary fsync improves crash consistency but is not `F_FULLFSYNC`, so sudden power loss on macOS/APFS is not an absolute durability guarantee; where the platform cannot fsync the parent directory, a crash may leave the old or the new state file (both valid), never a partially-written one. Existing active goals recover from disk with their full objective, budget, history, and checkpoint metadata.

If the rename succeeds but syncing the parent directory reports a genuine I/O error, the mutation reports a write failure even though the new valid state may already be present. This avoids claiming durability that the filesystem did not confirm.

## Credits

Expand Down
124 changes: 114 additions & 10 deletions dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,120 @@
import { z } from "zod";

// src/state.ts
import { chmod, mkdir, readFile, rename, writeFile } from "fs/promises";
import { mkdir, readFile } from "fs/promises";
import { homedir } from "os";
import { dirname, join } from "path";
import { dirname as dirname2, join } from "path";
import { Data, Effect, Schema } from "effect";

// src/atomic-write.ts
import { randomUUID } from "crypto";
import { chmod, open, rename, unlink } from "fs/promises";
import { dirname } from "path";
function isUnsupportedSyncDirError(error, platform) {
const code = error?.code;
return code === "EINVAL" || code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EISDIR" || platform === "win32" && (code === "EPERM" || code === "EACCES" || code === "EBADF");
}
function isTransientRenameError(error, platform) {
if (platform !== "win32")
return false;
const code = error?.code;
return code === "EPERM" || code === "EACCES" || code === "EBUSY";
}
async function bestEffort(action) {
try {
await action();
} catch {}
}
var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
var defaultDirOpenOps = {
async open(dir, flags) {
const handle = await open(dir, flags);
return {
sync: () => handle.sync(),
close: () => handle.close()
};
}
};
async function syncDirectory(dir, ops = defaultDirOpenOps, platform = process.platform) {
let fsHandle = null;
try {
const handle = await ops.open(dir, "r");
fsHandle = handle;
await handle.sync();
} catch (error) {
if (!isUnsupportedSyncDirError(error, platform))
throw error;
} finally {
const cleanupHandle = fsHandle;
if (cleanupHandle)
await bestEffort(() => cleanupHandle.close());
}
}
var defaultAtomicWriteOps = {
platform: process.platform,
async open(path, flags, mode) {
const handle = await open(path, flags, mode);
return {
write: (data) => handle.writeFile(data),
sync: () => handle.sync(),
close: () => handle.close()
};
},
rename,
chmod,
unlink,
syncDir: (dir, platform) => syncDirectory(dir, defaultDirOpenOps, platform),
sleep: defaultSleep
};
var RENAME_ATTEMPTS = 3;
var RENAME_RETRY_DELAY_MS = 20;
async function renameWithRetry(ops, from, to) {
let attempt = 0;
for (;; ) {
try {
await ops.rename(from, to);
return;
} catch (error) {
attempt += 1;
if (!isTransientRenameError(error, ops.platform) || attempt >= RENAME_ATTEMPTS)
throw error;
const delay = RENAME_RETRY_DELAY_MS * attempt;
await ops.sleep(delay);
}
}
}
async function atomicWriteFile(file, data, ops = defaultAtomicWriteOps) {
const tmp = `${file}.${randomUUID()}.tmp`;
let handle = null;
let created = false;
let renamed = false;
try {
handle = await ops.open(tmp, "wx", 384);
created = true;
await handle.write(data);
await handle.sync();
await handle.close();
handle = null;
await renameWithRetry(ops, tmp, file);
renamed = true;
await bestEffort(() => ops.chmod(file, 384));
try {
await ops.syncDir(dirname(file), ops.platform);
} catch (error) {
if (!isUnsupportedSyncDirError(error, ops.platform))
throw error;
}
} catch (error) {
const cleanupHandle = handle;
if (cleanupHandle)
await bestEffort(() => cleanupHandle.close());
if (created && !renamed)
await bestEffort(() => ops.unlink(tmp));
throw error;
}
}

// src/state.ts
class StateReadError extends Data.TaggedError("StateReadError") {
}

Expand Down Expand Up @@ -117,14 +226,9 @@ function writeStateEffect(state) {
return Effect.tryPromise({
try: async () => {
const file = statePath();
await mkdir(dirname(file), { recursive: true, mode: 448 });
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
await writeFile(tmp, JSON.stringify(state, null, 2) + `
`, { mode: 384 });
await rename(tmp, file);
await chmod(file, 384).catch(() => {
return;
});
await mkdir(dirname2(file), { recursive: true, mode: 448 });
await atomicWriteFile(file, JSON.stringify(state, null, 2) + `
`);
},
catch: (cause) => new StateWriteError({ cause })
});
Expand Down
Loading