@bybrawe/opencode-loop 0.6.7 → 0.6.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +2 -2
- package/examples/OPENCODE2-0.6.8.md +18 -0
- package/package.json +3 -3
- package/scripts/native-release.mjs +3 -3
- package/scripts/publish-workflow-test.mjs +2 -2
- package/scripts/state-exfat-lock-test.mjs +99 -0
- package/src/index.js +32 -0
- package/src/native.js +32 -0
- package/src/server.js +32 -0
- package/src/source/core/state.js +33 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
No unreleased changes yet.
|
|
6
6
|
|
|
7
|
+
## 0.6.8 - 2026-10-13
|
|
8
|
+
|
|
9
|
+
- Fix #172: fall back to exclusive `wx` lock claims where exFAT/FAT or other volumes reject `fs.link()`, including the reported `EISDIR` error.
|
|
10
|
+
- Preserve live-owner exclusion, dead-owner cleanup elections, crash-safe state updates and fail-closed corrupted locks.
|
|
11
|
+
- Exercise simulated unsupported hard-link errors across independent workers, merged writes, recovered dead owners and unexpected errors.
|
|
12
|
+
- Pair the new release with independently installed Goal 1.3.49 and require Windows/Linux and native OpenCode 2 release gates.
|
|
13
|
+
- The reported physical Windows exFAT case still needs re-testing on its actual disk.
|
|
14
|
+
|
|
7
15
|
## 0.6.7 - 2026-10-09
|
|
8
16
|
|
|
9
17
|
- Recover the native Loop+Goal coexistence canary's cold Windows server bootstrap with bounded read-only session polling and distinguish server startup hangs from actual autonomous-ownership conflicts.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# OpenCode Loop
|
|
2
2
|
|
|
3
|
-
## Native OpenCode 2 (0.6.
|
|
3
|
+
## Native OpenCode 2 (0.6.8)
|
|
4
4
|
|
|
5
5
|
The npm and V2 entrypoints use the official `@opencode/plugin` `Plugin.define()` contract. The installer now registers the exact `@bybrawe/opencode-loop` package in OpenCode's plural `plugins` configuration instead of copying a loose global plugin directory. OpenCode therefore resolves `@opencode/plugin` from Loop's own production dependency graph; explicit `--legacy-v1` remains a separate compatibility path.
|
|
6
6
|
|
|
@@ -17,7 +17,7 @@ Prompt/command timers, watch and stop conditions, verification, preflight/postru
|
|
|
17
17
|
|
|
18
18
|
OpenCode Loop adds `/loop`, scheduled prompt/command/shell jobs, compact scheduling, verification/checkpoints, and the `opencode-loopd` background daemon.
|
|
19
19
|
|
|
20
|
-
> **Current stable release: `0.6.
|
|
20
|
+
> **Current stable release: `0.6.8`.**
|
|
21
21
|
|
|
22
22
|
## Install or update
|
|
23
23
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# OpenCode Loop 0.6.8
|
|
2
|
+
|
|
3
|
+
## exFAT/FAT project volume lock compatibility (#172)
|
|
4
|
+
|
|
5
|
+
When `fs.link()` cannot claim a Loop session lock because hard links are unsupported (`EISDIR` on the reported Windows exFAT case, or `ENOTSUP`/`EOPNOTSUPP`/`ENOSYS`/`EPERM`/`EACCES`), Loop attempts an atomic `wx` exclusive-file claim instead.
|
|
6
|
+
|
|
7
|
+
The fallback also applies to dead-owner cleanup elections. A different live owner always wins; malformed lock owner metadata remains fail-closed, and unexpected I/O errors are not ignored. Short empty-owner publication windows get bounded retries instead of false corruption reports.
|
|
8
|
+
|
|
9
|
+
Tests exercise synthetic exFAT-style errors in independent Node processes, concurrent updates, abandoned locks and broken metadata. Windows/Linux, real OpenCode 2 and production artifact validation remain release gates. **Physical Windows exFAT and Desktop GUI behavior require separate field verification.**
|
|
10
|
+
|
|
11
|
+
## Independent installation
|
|
12
|
+
|
|
13
|
+
```powershell
|
|
14
|
+
npx -y @bybrawe/opencode-loop@0.6.8
|
|
15
|
+
npx -y @bybrawe/opencode-goal@1.3.49
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Fully exit and restart OpenCode. From the affected exFAT project, verify `/loop-status`, `/loop-doctor`, a short `/loop` job, and `/goal status`. Dedicated Goal remains a separately installed plugin; `/loop-goal*` compatibility commands are opt-in.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bybrawe/opencode-loop",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.8",
|
|
4
4
|
"description": "Idle-safe /loop scheduling and background automation for OpenCode, with optional legacy Loop Goal compatibility and a separately installable OpenCode Goal companion.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/plugin.js",
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"build:plugin": "bun build src/source/v1.js --outfile=src/index.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/server.js --outfile=src/server.js --target=bun --format=esm --external=@opencode-ai/plugin/tool && bun build src/source/native.js --outfile=src/native.js --target=bun --format=esm",
|
|
25
25
|
"build:plugin:npm": "npm run build:plugin",
|
|
26
26
|
"prepack": "node --check src/plugin.js && node --check src/v2.js && node --check src/index.js && node --check src/server.js && node --check src/native.js",
|
|
27
|
-
"check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/pulse-check.mjs && node --check scripts/state-process-test.mjs && node --check scripts/state-integrity-test.mjs && node --check scripts/install-test.mjs && node --check scripts/desktop-command-diagnostics.mjs && node --check scripts/desktop-command-diagnostics-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-goal-bootstrap.mjs && node --check scripts/host-goal-bootstrap-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs && node --check scripts/goal-compaction-safety-test.mjs && node --check src/source/native.js && node --check src/source/opencode2/native-plugin.js && node --check src/source/opencode2/native-runtime.js && node --check src/source/opencode2/native-policy.js && node --check src/native.js && node --check scripts/native-entry-test.mjs && node --check scripts/package-native-smoke.mjs && node --check src/v1.js && node --check scripts/native-cli-io-test.mjs && node --check scripts/native-lifecycle-test.mjs && node --check scripts/native-plugin-lifetime-test.mjs && node --check src/source/core/progress.js && node --check scripts/native-presets-test.mjs && node --check scripts/control-plane-until-test.mjs && node --check src/source/opencode2/session-location.js && node --check scripts/native-session-location-test.mjs && node --check src/plugin.js && node --check src/v2.js",
|
|
28
|
-
"test": "node --test scripts/native-session-location-test.mjs && node --test scripts/control-plane-until-test.mjs && node --test scripts/native-presets-test.mjs && node --test scripts/native-plugin-lifetime-test.mjs && node --test scripts/native-cli-io-test.mjs scripts/native-lifecycle-test.mjs && node scripts/native-entry-test.mjs && node scripts/native-coexistence-test.mjs && node scripts/native-shell-test.mjs && node scripts/native-install-test.mjs && node scripts/native-v2-test.mjs && node scripts/goal-compaction-safety-test.mjs && node scripts/publish-workflow-test.mjs && node scripts/server-entry-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/session-error-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/state-process-test.mjs && node scripts/state-integrity-test.mjs && node scripts/install-test.mjs && node --test scripts/desktop-command-diagnostics-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node --test scripts/host-goal-bootstrap-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
27
|
+
"check": "node --check src/source/v1.js && node --check src/source/core/args.js && node --check src/source/core/continuation.js && node --check src/source/core/schedule-syntax.js && node --check src/source/core/state.js && node --check src/source/core/jobs.js && node --check src/source/core/process.js && node --check src/source/opencode/sdk.js && node --check src/source/opencode/session-context.js && node --check src/source/opencode/command-router.js && node --check src/source/opencode/goal-commands.js && node --check src/source/opencode/loop-commands.js && node --check src/source/opencode/loop-registration.js && node --check src/source/runtime/session-activity.js && node --check src/source/runtime/session-status.js && node --check src/source/runtime/schedule-policy.js && node --check src/source/runtime/scheduler-diagnostics.js && node --check src/source/runtime/companion-goal.js && node --check src/source/runtime/loop-diagnostics.js && node --check src/source/runtime/compaction.js && node --check src/source/runtime/action-dispatch.js && node --check src/source/runtime/network-recovery.js && node --check src/source/runtime/empty-turn.js && node --check src/source/runtime/terminal-guard.js && node --check src/source/runtime/run-finalization.js && node --check src/source/runtime/run-admission.js && node --check src/source/runtime/executor.js && node --check src/source/runtime/scheduler.js && node --check src/source/runtime/goal-prompt.js && node --check src/source/runtime/goal-report.js && node --check src/source/runtime/goal-evidence.js && node --check src/source/runtime/goal-runtime.js && node --check src/source/runtime/goal-policy.js && node --check src/source/runtime/goal-steering.js && node --check src/source/runtime/job-workspace.js && node --check src/source/opencode2/prompt-runtime.js && node --check src/source/opencode2/diagnostics.js && node --check src/source/opencode2/logging.js && node --check src/source/server.js && node --check src/source/legacy-v1.js && node --check src/index.js && node --check src/server.js && node --check scripts/install-node.mjs && node --check scripts/install-with-goals.mjs && node --check scripts/loopd.mjs && node --check scripts/pulse-check.mjs && node --check scripts/state-process-test.mjs && node --check scripts/state-exfat-lock-test.mjs && node --check scripts/state-integrity-test.mjs && node --check scripts/install-test.mjs && node --check scripts/desktop-command-diagnostics.mjs && node --check scripts/desktop-command-diagnostics-test.mjs && node --check scripts/goal-companion-test.mjs && node --check scripts/loopd-test.mjs && node --check scripts/smoke-test.mjs && node --check scripts/host-adapter-contract-test.mjs && node --check scripts/server-entry-test.mjs && node --check scripts/command-router-test.mjs && node --check scripts/goal-command-handlers-test.mjs && node --check scripts/loop-command-handlers-test.mjs && node --check scripts/loop-registration-test.mjs && node --check scripts/schedule-syntax-test.mjs && node --check scripts/schedule-policy-test.mjs && node --check scripts/session-activity-test.mjs && node --check scripts/session-status-test.mjs && node --check scripts/session-status-idle-recovery-test.mjs && node --check scripts/session-error-recovery-test.mjs && node --check scripts/empty-turn-recovery-test.mjs && node --check scripts/network-recovery-test.mjs && node --check scripts/compaction-runtime-test.mjs && node --check scripts/executor-runtime-test.mjs && node --check scripts/scheduler-runtime-test.mjs && node --check scripts/goal-runtime-test.mjs && node --check scripts/goal-policy-test.mjs && node --check scripts/goal-steering-test.mjs && node --check scripts/job-workspace-test.mjs && node --check scripts/v2-prompt-runtime-test.mjs && node --check scripts/v2-prompt-interval-test.mjs && node --check scripts/v2-command-runtime-test.mjs && node --check scripts/v2-command-adapter-test.mjs && node --check scripts/v2-diagnostics-test.mjs && node --check scripts/v2-logging-test.mjs && node --check scripts/comprehensive-watchdog.mjs && node --check scripts/comprehensive-test.mjs && node --check scripts/host-goal-bootstrap.mjs && node --check scripts/host-goal-bootstrap-test.mjs && node --check scripts/host-loop-canary.mjs && node --check scripts/host-goal-steering-canary.mjs && node --check scripts/publish-workflow-test.mjs && node --check scripts/goal-compaction-safety-test.mjs && node --check src/source/native.js && node --check src/source/opencode2/native-plugin.js && node --check src/source/opencode2/native-runtime.js && node --check src/source/opencode2/native-policy.js && node --check src/native.js && node --check scripts/native-entry-test.mjs && node --check scripts/package-native-smoke.mjs && node --check src/v1.js && node --check scripts/native-cli-io-test.mjs && node --check scripts/native-lifecycle-test.mjs && node --check scripts/native-plugin-lifetime-test.mjs && node --check src/source/core/progress.js && node --check scripts/native-presets-test.mjs && node --check scripts/control-plane-until-test.mjs && node --check src/source/opencode2/session-location.js && node --check scripts/native-session-location-test.mjs && node --check src/plugin.js && node --check src/v2.js",
|
|
28
|
+
"test": "node --test scripts/native-session-location-test.mjs && node --test scripts/control-plane-until-test.mjs && node --test scripts/native-presets-test.mjs && node --test scripts/native-plugin-lifetime-test.mjs && node --test scripts/native-cli-io-test.mjs scripts/native-lifecycle-test.mjs && node scripts/native-entry-test.mjs && node scripts/native-coexistence-test.mjs && node scripts/native-shell-test.mjs && node scripts/native-install-test.mjs && node scripts/native-v2-test.mjs && node scripts/goal-compaction-safety-test.mjs && node scripts/publish-workflow-test.mjs && node scripts/server-entry-test.mjs && node scripts/command-router-test.mjs && node scripts/goal-command-handlers-test.mjs && node scripts/loop-command-handlers-test.mjs && node scripts/loop-registration-test.mjs && node scripts/schedule-syntax-test.mjs && node scripts/schedule-policy-test.mjs && node scripts/session-activity-test.mjs && node scripts/session-status-test.mjs && node scripts/session-status-idle-recovery-test.mjs && node scripts/session-error-recovery-test.mjs && node scripts/empty-turn-recovery-test.mjs && node scripts/network-recovery-test.mjs && node scripts/compaction-runtime-test.mjs && node scripts/action-dispatch-test.mjs && node scripts/run-finalization-test.mjs && node scripts/run-admission-test.mjs && node scripts/executor-runtime-test.mjs && node scripts/scheduler-runtime-test.mjs && node scripts/goal-runtime-test.mjs && node scripts/goal-policy-test.mjs && node scripts/goal-steering-test.mjs && node scripts/job-workspace-test.mjs && node scripts/v2-prompt-runtime-test.mjs && node scripts/v2-prompt-interval-test.mjs && node scripts/v2-command-runtime-test.mjs && node scripts/v2-command-adapter-test.mjs && node scripts/v2-diagnostics-test.mjs && node scripts/v2-logging-test.mjs && node scripts/state-process-test.mjs && node scripts/state-exfat-lock-test.mjs && node scripts/state-integrity-test.mjs && node scripts/install-test.mjs && node --test scripts/desktop-command-diagnostics-test.mjs && node scripts/goal-companion-test.mjs && node scripts/loopd-test.mjs && node scripts/smoke-test.mjs && node --test scripts/host-goal-bootstrap-test.mjs && node scripts/host-adapter-contract-test.mjs && node scripts/comprehensive-watchdog.mjs",
|
|
29
29
|
"canary:host": "node scripts/host-loop-canary.mjs && node scripts/host-goal-steering-canary.mjs",
|
|
30
30
|
"install:global": "node scripts/install-with-goals.mjs",
|
|
31
31
|
"pack:zip": "node scripts/make-zip.mjs",
|
|
@@ -7,12 +7,12 @@ export function validateRelease(request, pkg, env) {
|
|
|
7
7
|
assert.equal(env.GITHUB_REPOSITORY, "ByBrawe/opencode-loop", "wrong release repository")
|
|
8
8
|
assert.equal(env.GITHUB_REF, "refs/heads/main", "npm release must come from main")
|
|
9
9
|
assert.match(env.GITHUB_SHA ?? "", /^[a-f0-9]{40}$/, "immutable release SHA required")
|
|
10
|
-
assert.equal(request?.version, "0.6.
|
|
10
|
+
assert.equal(request?.version, "0.6.8", "release version is not authorized")
|
|
11
11
|
assert.equal(request?.name, "@bybrawe/opencode-loop")
|
|
12
12
|
assert.equal(pkg.name, request.name)
|
|
13
13
|
assert.equal(pkg.version, request.version)
|
|
14
|
-
assert.equal(request.predecessor, "0.6.
|
|
15
|
-
assert.equal(request.goal?.version, "1.3.
|
|
14
|
+
assert.equal(request.predecessor, "0.6.7")
|
|
15
|
+
assert.equal(request.goal?.version, "1.3.49")
|
|
16
16
|
assert.match(request.goal?.sha ?? "", /^[a-f0-9]{40}$/)
|
|
17
17
|
return request
|
|
18
18
|
}
|
|
@@ -48,14 +48,14 @@ assert.ok(workflow.includes('--prefer-online --cache "$VERIFY_DIR/npm-cache-$att
|
|
|
48
48
|
assert.ok(workflow.includes("Published Loop+Goal clean-install validation did not converge"), "clean consumer must remain a hard release gate")
|
|
49
49
|
|
|
50
50
|
const sha = "a".repeat(40)
|
|
51
|
-
const request = { name: "@bybrawe/opencode-loop", version: "0.6.
|
|
51
|
+
const request = { name: "@bybrawe/opencode-loop", version: "0.6.8", predecessor: "0.6.7", goal: { version: "1.3.49", sha: "b".repeat(40) } }
|
|
52
52
|
const pkg = { name: request.name, version: request.version }
|
|
53
53
|
const env = { GITHUB_REPOSITORY: "ByBrawe/opencode-loop", GITHUB_REF: "refs/heads/main", GITHUB_SHA: sha }
|
|
54
54
|
assert.strictEqual(validateRelease(request, pkg, env), request)
|
|
55
55
|
for (const change of [{ GITHUB_REPOSITORY: "someone/fork" }, { GITHUB_REF: "refs/heads/release" }, { GITHUB_SHA: "main" }]) assert.throws(() => validateRelease(request, pkg, { ...env, ...change }))
|
|
56
56
|
assert.throws(() => validateRelease({ ...request, version: "9.0.0" }, pkg, env))
|
|
57
57
|
assert.throws(() => validateRelease(request, { ...pkg, version: "0.6.5" }, env))
|
|
58
|
-
assert.equal(releaseDecision(404, null, request, sha, "0.6.
|
|
58
|
+
assert.equal(releaseDecision(404, null, request, sha, "0.6.7"), true)
|
|
59
59
|
for (const status of [401, 403, 429, 500, 503]) assert.throws(() => releaseDecision(status, null, request, sha, "0.6.0"))
|
|
60
60
|
assert.throws(() => releaseDecision(404, null, request, sha, "0.7.0"))
|
|
61
61
|
const manifest = { ...pkg, gitHead: sha }
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict"
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
3
|
+
import { randomUUID } from "node:crypto"
|
|
4
|
+
import { promises as fs } from "node:fs"
|
|
5
|
+
import os from "node:os"
|
|
6
|
+
import path from "node:path"
|
|
7
|
+
import { fileURLToPath } from "node:url"
|
|
8
|
+
import { readState, writeState, stateDir } from "../src/source/core/state.js"
|
|
9
|
+
|
|
10
|
+
const source = fileURLToPath(import.meta.url)
|
|
11
|
+
const originalLink = fs.link
|
|
12
|
+
const linkError = (code) => Object.assign(new Error(`synthetic unsupported hard link: ${code}`), { code })
|
|
13
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
14
|
+
async function exists(file) { try { await fs.access(file); return true } catch (error) { if (error?.code === "ENOENT") return false; throw error } }
|
|
15
|
+
async function until(fn) { for (let i = 0; i < 500; i++) { if (await fn()) return; await wait(20) } throw new Error("worker barrier timeout") }
|
|
16
|
+
|
|
17
|
+
if (process.argv[2] === "--worker") {
|
|
18
|
+
fs.link = async () => { throw linkError("EISDIR") }
|
|
19
|
+
try {
|
|
20
|
+
const [, , , root, sessionID, label, gate] = process.argv
|
|
21
|
+
const snapshot = await readState(root, sessionID)
|
|
22
|
+
await fs.writeFile(`${gate}.${label}.ready`, "ready")
|
|
23
|
+
await until(() => exists(gate))
|
|
24
|
+
snapshot.jobs[0][label] = label
|
|
25
|
+
await writeState(root, sessionID, snapshot)
|
|
26
|
+
} finally { fs.link = originalLink }
|
|
27
|
+
} else {
|
|
28
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), "loop-exfat-lock-"))
|
|
29
|
+
const sessionID = "exfat-session"
|
|
30
|
+
const canonical = path.join(stateDir(root), `${sessionID}.json.lock`)
|
|
31
|
+
fs.link = async () => { throw linkError("EISDIR") }
|
|
32
|
+
try {
|
|
33
|
+
const original = await readState(root, sessionID)
|
|
34
|
+
original.jobs = [{ id: "one", round: 1 }]
|
|
35
|
+
await writeState(root, sessionID, original)
|
|
36
|
+
|
|
37
|
+
// Workers each simulate an exFAT runtime: independent Node processes with
|
|
38
|
+
// no usable hard-link support must still merge their distinct writes.
|
|
39
|
+
const gate = path.join(root, "go")
|
|
40
|
+
const workers = []
|
|
41
|
+
for (let i = 0; i < 4; i++) {
|
|
42
|
+
const label = `worker${i}`
|
|
43
|
+
const child = spawn(process.execPath, [source, "--worker", root, sessionID, label, gate], { stdio: ["ignore", "ignore", "pipe"] })
|
|
44
|
+
let stderr = ""
|
|
45
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString() })
|
|
46
|
+
workers.push({
|
|
47
|
+
label,
|
|
48
|
+
done: new Promise((resolve, reject) => {
|
|
49
|
+
child.once("error", reject)
|
|
50
|
+
child.once("close", (code) => code === 0 ? resolve() : reject(new Error(`exFAT worker ${label} failed: ${stderr}`)))
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
await until(async () => (await Promise.all(workers.map(w => exists(`${gate}.${w.label}.ready`)))).every(Boolean))
|
|
55
|
+
await fs.writeFile(gate, "go")
|
|
56
|
+
await Promise.all(workers.map(w => w.done))
|
|
57
|
+
const saved = (await readState(root, sessionID)).jobs[0]
|
|
58
|
+
for (const w of workers) assert.equal(saved[w.label], w.label)
|
|
59
|
+
assert.equal(await exists(canonical), false)
|
|
60
|
+
|
|
61
|
+
// Simulate an abandoned primary lock and an orphaned cleanup election,
|
|
62
|
+
// both written without hard links. Neither may prevent dead-PID recovery.
|
|
63
|
+
const dead = spawnSync(process.execPath, ["-e", "process.exit(0)"])
|
|
64
|
+
assert.equal(dead.status, 0)
|
|
65
|
+
const deadOwner = () => {
|
|
66
|
+
const token = randomUUID()
|
|
67
|
+
return { schemaVersion: 1, pid: dead.pid, token, acquiredAt: Date.now(),
|
|
68
|
+
candidateName: `.loop-lock-${dead.pid}-${token}.json` }
|
|
69
|
+
}
|
|
70
|
+
for (const suffix of ["", ".cleanup"]) {
|
|
71
|
+
const owner = deadOwner()
|
|
72
|
+
await fs.writeFile(path.join(stateDir(root), owner.candidateName), JSON.stringify(owner))
|
|
73
|
+
await fs.writeFile(`${canonical}${suffix}`, JSON.stringify(owner))
|
|
74
|
+
}
|
|
75
|
+
const revived = await readState(root, sessionID)
|
|
76
|
+
revived.jobs[0].recovered = true
|
|
77
|
+
await writeState(root, sessionID, revived)
|
|
78
|
+
assert.equal((await readState(root, sessionID)).jobs[0].recovered, true)
|
|
79
|
+
assert.equal(await exists(canonical), false)
|
|
80
|
+
assert.equal(await exists(`${canonical}.cleanup`), false)
|
|
81
|
+
|
|
82
|
+
await fs.writeFile(canonical, "invalid owner")
|
|
83
|
+
const corrupt = await readState(root, sessionID)
|
|
84
|
+
corrupt.jobs[0].forbidden = "never commit"
|
|
85
|
+
await assert.rejects(writeState(root, sessionID, corrupt), /state lock invalid_owner/)
|
|
86
|
+
assert.equal((await readState(root, sessionID)).jobs[0].forbidden, undefined)
|
|
87
|
+
await fs.rm(canonical)
|
|
88
|
+
|
|
89
|
+
// Unexpected failures must not be turned into 'contention' or ignored.
|
|
90
|
+
fs.link = async () => { throw linkError("ENOSPC") }
|
|
91
|
+
await assert.rejects(writeState(root, sessionID, await readState(root, sessionID)), { code: "ENOSPC" })
|
|
92
|
+
assert.equal(await exists(canonical), false)
|
|
93
|
+
|
|
94
|
+
console.log("Loop exFAT-style exclusive lock, process merge, dead-owner election and corrupt-state protections passed")
|
|
95
|
+
} finally {
|
|
96
|
+
fs.link = originalLink
|
|
97
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 })
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/index.js
CHANGED
|
@@ -459,6 +459,18 @@ async function readStateProcessOwner(file) {
|
|
|
459
459
|
await delay(10 * (attempt + 1));
|
|
460
460
|
}
|
|
461
461
|
}
|
|
462
|
+
if (raw.length === 0) {
|
|
463
|
+
for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
|
|
464
|
+
await delay(10 * (attempt + 1));
|
|
465
|
+
try {
|
|
466
|
+
raw = await fs.readFile(file, "utf8");
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if (error?.code === "ENOENT")
|
|
469
|
+
return null;
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
462
474
|
let owner;
|
|
463
475
|
try {
|
|
464
476
|
owner = JSON.parse(raw);
|
|
@@ -499,6 +511,26 @@ async function claimStateProcessLock(candidate, canonical) {
|
|
|
499
511
|
} catch (error) {
|
|
500
512
|
if (error?.code === "EEXIST")
|
|
501
513
|
return false;
|
|
514
|
+
if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
|
|
515
|
+
const contents = await fs.readFile(candidate);
|
|
516
|
+
let handle;
|
|
517
|
+
try {
|
|
518
|
+
handle = await fs.open(canonical, "wx", 384);
|
|
519
|
+
} catch (failure) {
|
|
520
|
+
if (failure?.code === "EEXIST")
|
|
521
|
+
return false;
|
|
522
|
+
throw failure;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
await handle.writeFile(contents);
|
|
526
|
+
} catch (failure) {
|
|
527
|
+
await handle.close().catch(() => {});
|
|
528
|
+
await removeStateProcessFile(canonical).catch(() => {});
|
|
529
|
+
throw failure;
|
|
530
|
+
}
|
|
531
|
+
await handle.close();
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
502
534
|
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
503
535
|
throw error;
|
|
504
536
|
await delay(10 * (attempt + 1));
|
package/src/native.js
CHANGED
|
@@ -612,6 +612,18 @@ async function readStateProcessOwner(file) {
|
|
|
612
612
|
await delay(10 * (attempt + 1));
|
|
613
613
|
}
|
|
614
614
|
}
|
|
615
|
+
if (raw.length === 0) {
|
|
616
|
+
for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
|
|
617
|
+
await delay(10 * (attempt + 1));
|
|
618
|
+
try {
|
|
619
|
+
raw = await fs.readFile(file, "utf8");
|
|
620
|
+
} catch (error) {
|
|
621
|
+
if (error?.code === "ENOENT")
|
|
622
|
+
return null;
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
615
627
|
let owner;
|
|
616
628
|
try {
|
|
617
629
|
owner = JSON.parse(raw);
|
|
@@ -652,6 +664,26 @@ async function claimStateProcessLock(candidate, canonical) {
|
|
|
652
664
|
} catch (error) {
|
|
653
665
|
if (error?.code === "EEXIST")
|
|
654
666
|
return false;
|
|
667
|
+
if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
|
|
668
|
+
const contents = await fs.readFile(candidate);
|
|
669
|
+
let handle;
|
|
670
|
+
try {
|
|
671
|
+
handle = await fs.open(canonical, "wx", 384);
|
|
672
|
+
} catch (failure) {
|
|
673
|
+
if (failure?.code === "EEXIST")
|
|
674
|
+
return false;
|
|
675
|
+
throw failure;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
await handle.writeFile(contents);
|
|
679
|
+
} catch (failure) {
|
|
680
|
+
await handle.close().catch(() => {});
|
|
681
|
+
await removeStateProcessFile(canonical).catch(() => {});
|
|
682
|
+
throw failure;
|
|
683
|
+
}
|
|
684
|
+
await handle.close();
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
655
687
|
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
656
688
|
throw error;
|
|
657
689
|
await delay(10 * (attempt + 1));
|
package/src/server.js
CHANGED
|
@@ -612,6 +612,18 @@ async function readStateProcessOwner(file) {
|
|
|
612
612
|
await delay(10 * (attempt + 1));
|
|
613
613
|
}
|
|
614
614
|
}
|
|
615
|
+
if (raw.length === 0) {
|
|
616
|
+
for (let attempt = 0;attempt < 8 && raw.length === 0; attempt++) {
|
|
617
|
+
await delay(10 * (attempt + 1));
|
|
618
|
+
try {
|
|
619
|
+
raw = await fs.readFile(file, "utf8");
|
|
620
|
+
} catch (error) {
|
|
621
|
+
if (error?.code === "ENOENT")
|
|
622
|
+
return null;
|
|
623
|
+
throw error;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
615
627
|
let owner;
|
|
616
628
|
try {
|
|
617
629
|
owner = JSON.parse(raw);
|
|
@@ -652,6 +664,26 @@ async function claimStateProcessLock(candidate, canonical) {
|
|
|
652
664
|
} catch (error) {
|
|
653
665
|
if (error?.code === "EEXIST")
|
|
654
666
|
return false;
|
|
667
|
+
if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
|
|
668
|
+
const contents = await fs.readFile(candidate);
|
|
669
|
+
let handle;
|
|
670
|
+
try {
|
|
671
|
+
handle = await fs.open(canonical, "wx", 384);
|
|
672
|
+
} catch (failure) {
|
|
673
|
+
if (failure?.code === "EEXIST")
|
|
674
|
+
return false;
|
|
675
|
+
throw failure;
|
|
676
|
+
}
|
|
677
|
+
try {
|
|
678
|
+
await handle.writeFile(contents);
|
|
679
|
+
} catch (failure) {
|
|
680
|
+
await handle.close().catch(() => {});
|
|
681
|
+
await removeStateProcessFile(canonical).catch(() => {});
|
|
682
|
+
throw failure;
|
|
683
|
+
}
|
|
684
|
+
await handle.close();
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
655
687
|
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5)
|
|
656
688
|
throw error;
|
|
657
689
|
await delay(10 * (attempt + 1));
|
package/src/source/core/state.js
CHANGED
|
@@ -68,6 +68,19 @@ async function readStateProcessOwner(file) {
|
|
|
68
68
|
await delay(10 * (attempt + 1))
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
|
+
// An exclusive-file fallback publishes an empty lock briefly before
|
|
72
|
+
// writing its owner. Allow a bounded read retry; stale empty locks still
|
|
73
|
+
// fail closed rather than being taken over.
|
|
74
|
+
if (raw.length === 0) {
|
|
75
|
+
for (let attempt = 0; attempt < 8 && raw.length === 0; attempt++) {
|
|
76
|
+
await delay(10 * (attempt + 1))
|
|
77
|
+
try { raw = await fs.readFile(file, "utf8") }
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error?.code === "ENOENT") return null
|
|
80
|
+
throw error
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
71
84
|
let owner
|
|
72
85
|
try { owner = JSON.parse(raw) }
|
|
73
86
|
catch { throw stateLockError("invalid_owner", `corrupt lock metadata at ${file}`) }
|
|
@@ -91,11 +104,31 @@ async function removeStateProcessFile(file) {
|
|
|
91
104
|
}
|
|
92
105
|
}
|
|
93
106
|
|
|
107
|
+
// Keep atomic hard links on capable volumes. FAT/exFAT and some remote volumes
|
|
108
|
+
// reject them, so fall back to exclusive creation (O_EXCL) without overwriting
|
|
109
|
+
// any existing live lock or stale-owner cleanup election.
|
|
94
110
|
async function claimStateProcessLock(candidate, canonical) {
|
|
95
111
|
for (let attempt = 0; ; attempt++) {
|
|
96
112
|
try { await fs.link(candidate, canonical); return true }
|
|
97
113
|
catch (error) {
|
|
98
114
|
if (error?.code === "EEXIST") return false
|
|
115
|
+
if (["EISDIR", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"].includes(error?.code)) {
|
|
116
|
+
const contents = await fs.readFile(candidate)
|
|
117
|
+
let handle
|
|
118
|
+
try { handle = await fs.open(canonical, "wx", 0o600) }
|
|
119
|
+
catch (failure) {
|
|
120
|
+
if (failure?.code === "EEXIST") return false
|
|
121
|
+
throw failure
|
|
122
|
+
}
|
|
123
|
+
try { await handle.writeFile(contents) }
|
|
124
|
+
catch (failure) {
|
|
125
|
+
await handle.close().catch(() => {})
|
|
126
|
+
await removeStateProcessFile(canonical).catch(() => {})
|
|
127
|
+
throw failure
|
|
128
|
+
}
|
|
129
|
+
await handle.close()
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
99
132
|
if (process.platform !== "win32" || !["EPERM", "EACCES", "EBUSY"].includes(error?.code) || attempt >= 5) throw error
|
|
100
133
|
await delay(10 * (attempt + 1))
|
|
101
134
|
}
|