weir-harness 1.1.2 → 1.1.3
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/package.json +1 -1
- package/src/capabilities/lifecycle-snapshot.js +2 -0
- package/src/capabilities/store/checked-contract.d.ts +1 -0
- package/src/capabilities/store/fs-adapter.js +33 -3
- package/src/capabilities/store/liveness.js +37 -3
- package/src/capabilities/store/store.js +7 -2
- package/src/edit-lock/admin-recovery.js +8 -3
- package/src/edit-lock/admission.js +13 -3
- package/src/edit-lock/operation-history.js +2 -2
- package/src/edit-lock/read-authority.js +5 -2
- package/src/edit-lock/remote.js +27 -11
- package/src/edit-lock/reservation.js +4 -0
- package/src/edit-lock/resource-identity.js +38 -15
- package/src/edit-lock/runtime.js +3 -0
- package/src/edit-lock/stale-sweep.js +18 -1
- package/src/edit-lock/store.js +15 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weir-harness",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Weir \u2014 orchestrator-centric DSH agent preset: intent gate, category delegation, todo driver, pull-based background jobs, auto-compaction, hashline edit.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -93,6 +93,8 @@ function fsyncPath(path) {
|
|
|
93
93
|
|
|
94
94
|
/** fsync one directory entry change (mirrors the fs-adapter fsyncDir step). @param {string} dir */
|
|
95
95
|
function fsyncDirectory(dir) {
|
|
96
|
+
// Directory fsync is POSIX-only (EPERM on win32 — same gate as fs-adapter.js).
|
|
97
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux') return
|
|
96
98
|
const fd = openSync(dir, 'r')
|
|
97
99
|
try {
|
|
98
100
|
fsyncSync(fd)
|
|
@@ -18,6 +18,35 @@ import { link, mkdir, open, readFile, readdir, rename, unlink } from 'node:fs/pr
|
|
|
18
18
|
* }} StoreFs
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
// Directory fsync is a POSIX crash-durability idiom: on Windows the directory
|
|
22
|
+
// handle opens but sync fails EPERM (verified on Node v24, NTFS). The win32
|
|
23
|
+
// store row (SUPPORTED_PLATFORMS in store.js) therefore commits with
|
|
24
|
+
// file-level fsync + atomic rename only — the same platform stance the
|
|
25
|
+
// edit-lock reservation already takes (reservation.js SYNC_SUPPORTED). The
|
|
26
|
+
// durability tradeoff is documented in docs/features/session-capability-manager.md.
|
|
27
|
+
const DIR_SYNC_SUPPORTED = process.platform === 'darwin' || process.platform === 'linux'
|
|
28
|
+
|
|
29
|
+
// Windows file-locking etiquette: antivirus and the search indexer briefly
|
|
30
|
+
// hold brand-new files, so unlink/rename/link can fail transiently with
|
|
31
|
+
// EPERM/EACCES/EBUSY (observed in the cross-process suite). POSIX rows never
|
|
32
|
+
// see those codes from these operations; the win32 row retries a few times
|
|
33
|
+
// before surfacing the error.
|
|
34
|
+
const WINDOWS = process.platform === 'win32'
|
|
35
|
+
const TRANSIENT_LOCK = new Set(['EPERM', 'EACCES', 'EBUSY'])
|
|
36
|
+
|
|
37
|
+
/** @param {() => Promise<unknown>} op @returns {Promise<void>} */
|
|
38
|
+
async function retryTransientLock(op) {
|
|
39
|
+
for (let attempt = 0; ; attempt++) {
|
|
40
|
+
try {
|
|
41
|
+
await op()
|
|
42
|
+
return
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (!WINDOWS || !TRANSIENT_LOCK.has(/** @type {{ code?: string }} */ (error)?.code ?? '') || attempt >= 4) throw error
|
|
45
|
+
await new Promise(resolve => setTimeout(resolve, 10 * (attempt + 1)))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
21
50
|
/** @returns {StoreFs} */
|
|
22
51
|
export function createNodeFs() {
|
|
23
52
|
return {
|
|
@@ -38,14 +67,15 @@ export function createNodeFs() {
|
|
|
38
67
|
await handle.close()
|
|
39
68
|
}
|
|
40
69
|
},
|
|
41
|
-
link: (from, to) => link(from, to),
|
|
42
|
-
rename: (from, to) => rename(from, to),
|
|
43
|
-
unlink: path => unlink(path),
|
|
70
|
+
link: (from, to) => retryTransientLock(() => link(from, to)),
|
|
71
|
+
rename: (from, to) => retryTransientLock(() => rename(from, to)),
|
|
72
|
+
unlink: path => retryTransientLock(() => unlink(path)),
|
|
44
73
|
readdir: path => readdir(path),
|
|
45
74
|
async mkdirp(path) {
|
|
46
75
|
await mkdir(path, { recursive: true, mode: 0o700 })
|
|
47
76
|
},
|
|
48
77
|
async fsyncDir(path) {
|
|
78
|
+
if (!DIR_SYNC_SUPPORTED) return
|
|
49
79
|
const handle = await open(path, 'r')
|
|
50
80
|
try {
|
|
51
81
|
await handle.sync()
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// belongs to a process with a different OS start time (pid reuse), or the pid
|
|
4
4
|
// is ours but carries another boot nonce. Everything else is 'alive' or
|
|
5
5
|
// 'unknown', and neither may ever be reclaimed automatically.
|
|
6
|
-
import { execFile } from 'node:child_process'
|
|
6
|
+
import { execFile as nodeExecFile } from 'node:child_process'
|
|
7
7
|
import { randomBytes } from 'node:crypto'
|
|
8
8
|
import { hostname } from 'node:os'
|
|
9
9
|
|
|
@@ -24,18 +24,47 @@ const PROCESS_BOOT_NONCE = randomBytes(8).toString('hex')
|
|
|
24
24
|
/** @param {number} pid @returns {Promise<string|null>} */
|
|
25
25
|
export function psStartTime(pid) {
|
|
26
26
|
return new Promise(resolve => {
|
|
27
|
-
|
|
27
|
+
nodeExecFile('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 5000 }, (error, stdout) => {
|
|
28
28
|
const text = error ? '' : String(stdout).trim()
|
|
29
29
|
resolve(text.length > 0 ? text : null)
|
|
30
30
|
})
|
|
31
31
|
})
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
// Windows has no ps(1): the process start time comes from Windows PowerShell,
|
|
35
|
+
// present on every supported Windows host (verified: powershell.exe Get-Process
|
|
36
|
+
// StartTime.ToFileTimeUtc(), ~0.5s warm). The probe fails closed to null — an
|
|
37
|
+
// unreadable start time is 'unknown', never 'dead'. The pid is interpolated
|
|
38
|
+
// only after the integer check below and the callers' own validation
|
|
39
|
+
// (parseOwnerDoc requires a safe integer; state() re-checks before probing).
|
|
40
|
+
/**
|
|
41
|
+
* @param {number} pid
|
|
42
|
+
* @param {((file: string, args: string[], options: object, callback: (error: Error | null, stdout: string) => void) => void)} [execFileImpl]
|
|
43
|
+
* @returns {Promise<string|null>}
|
|
44
|
+
*/
|
|
45
|
+
export function windowsStartTime(pid, execFileImpl = /** @type {any} */ (nodeExecFile)) {
|
|
46
|
+
return new Promise(resolve => {
|
|
47
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) { resolve(null); return }
|
|
48
|
+
execFileImpl('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', `(Get-Process -Id ${pid}).StartTime.ToFileTimeUtc()`], { encoding: 'utf8', timeout: 5000, windowsHide: true }, (error, stdout) => {
|
|
49
|
+
const text = error ? '' : String(stdout).trim()
|
|
50
|
+
resolve(/^[0-9]+$/.test(text) ? text : null)
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Module-level self start-identity: the own-process start time never changes,
|
|
56
|
+
* so the default configuration pays the start-time probe once per process (on
|
|
57
|
+
* win32 that probe is a subprocess) instead of once per store instance.
|
|
58
|
+
* Injected pids, boot nonces or probes bypass this cache.
|
|
59
|
+
* @type {Promise<StartIdentity>|undefined} */
|
|
60
|
+
let selfIdentity
|
|
61
|
+
|
|
34
62
|
/**
|
|
35
63
|
* @param {{
|
|
36
64
|
* pid?: number,
|
|
37
65
|
* host?: string,
|
|
38
66
|
* bootNonce?: string,
|
|
67
|
+
* platform?: string,
|
|
39
68
|
* kill?: (pid: number) => void,
|
|
40
69
|
* startTime?: (pid: number) => Promise<string|null>,
|
|
41
70
|
* }} [options]
|
|
@@ -46,7 +75,8 @@ export function createLiveness(options = {}) {
|
|
|
46
75
|
const host = options.host ?? hostname()
|
|
47
76
|
const bootNonce = options.bootNonce ?? PROCESS_BOOT_NONCE
|
|
48
77
|
const kill = options.kill ?? (target => { process.kill(target, 0) })
|
|
49
|
-
const
|
|
78
|
+
const platform = options.platform ?? process.platform
|
|
79
|
+
const startTime = options.startTime ?? (platform === 'win32' ? windowsStartTime : psStartTime)
|
|
50
80
|
/** @type {Promise<StartIdentity>|undefined} */
|
|
51
81
|
let self
|
|
52
82
|
|
|
@@ -64,6 +94,10 @@ export function createLiveness(options = {}) {
|
|
|
64
94
|
pid,
|
|
65
95
|
host,
|
|
66
96
|
identity() {
|
|
97
|
+
if (pid === process.pid && bootNonce === PROCESS_BOOT_NONCE && options.startTime === undefined) {
|
|
98
|
+
selfIdentity ??= startTime(pid).then(osStart => ({ osStart, bootNonce }))
|
|
99
|
+
return selfIdentity
|
|
100
|
+
}
|
|
67
101
|
self ??= startTime(pid).then(osStart => ({ osStart, bootNonce }))
|
|
68
102
|
return self
|
|
69
103
|
},
|
|
@@ -9,8 +9,13 @@ import { createLockProtocol } from './lock.js'
|
|
|
9
9
|
import { isFileSegment, isSegment, resolveStoreRoot, unitLayout } from './paths.js'
|
|
10
10
|
import { decodeRecord, digestOf, encodeRecord } from './record.js'
|
|
11
11
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Platforms whose rename/fsync/link matrix row is EXECUTED (C-1.10 §6 darwin
|
|
14
|
+
* row; win32 row: openspec windows-platform-adaptation — hard-link EEXIST,
|
|
15
|
+
* rename-replace, file fsync and kill(pid,0) verified on NTFS with Node v24.
|
|
16
|
+
* Directory fsync is skipped on win32, see fs-adapter.js).
|
|
17
|
+
*/
|
|
18
|
+
export const SUPPORTED_PLATFORMS = Object.freeze(['darwin', 'win32'])
|
|
14
19
|
|
|
15
20
|
/**
|
|
16
21
|
* @typedef {import('./paths.js').Unit} Unit
|
|
@@ -41,8 +41,9 @@ async function boundedRead(path, pins) {
|
|
|
41
41
|
verify(pins)
|
|
42
42
|
const before = lstatSync(path)
|
|
43
43
|
check(before.isFile() && !before.isSymbolicLink() && before.nlink === 1 && before.size <= MAX_RECOVERY_BYTES, 'Bounded single-link regular authority file required')
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// win32: O_NOFOLLOW/O_NONBLOCK are undefined; the lstat above refused a symlink
|
|
45
|
+
// leaf and the post-open identity check below bounds the swap (read-authority.js).
|
|
46
|
+
const file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
46
47
|
try {
|
|
47
48
|
const stat = await file.stat()
|
|
48
49
|
check(same(before, stat) && stat.isFile() && stat.nlink === 1 && stat.size <= MAX_RECOVERY_BYTES, 'Opened file identity or bound changed')
|
|
@@ -60,6 +61,8 @@ async function boundedRead(path, pins) {
|
|
|
60
61
|
} finally { await file.close() }
|
|
61
62
|
}
|
|
62
63
|
async function syncDirectory(directory) {
|
|
64
|
+
// Directory fsync is POSIX-only (EPERM on win32, reservation.js SYNC_SUPPORTED).
|
|
65
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux') return
|
|
63
66
|
const handle = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
|
64
67
|
try { await handle.sync() } finally { await handle.close() }
|
|
65
68
|
}
|
|
@@ -123,7 +126,9 @@ export async function recoverAuthority(input, testing = {}) {
|
|
|
123
126
|
check(savedBackup.equals(bytes), 'Existing backup does not match exact authority bytes')
|
|
124
127
|
parseSnapshot(savedBackup, input.root)
|
|
125
128
|
// Also sync an existing backup when retrying a crash before its first fsync.
|
|
126
|
-
|
|
129
|
+
// fsync needs a writable handle on Windows (EPERM on O_RDONLY): open the
|
|
130
|
+
// existing backup read-write there so the re-sync stays a real fsync.
|
|
131
|
+
const backupHandle = await open(join(directory, backup.file), process.platform === 'win32' ? 'r+' : constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
127
132
|
try { await backupHandle.sync() } finally { await backupHandle.close() }
|
|
128
133
|
await syncDirectory(directory)
|
|
129
134
|
await testing.checkpoint?.('backup-durable')
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { posix } from 'node:path'
|
|
1
|
+
import { posix, win32 } from 'node:path'
|
|
2
2
|
|
|
3
3
|
/** Candidates describe effects, not merely explicit API arguments. Session-wide
|
|
4
4
|
* operations must enumerate affected ownership at their FIFO position.
|
|
@@ -11,9 +11,19 @@ import { posix } from 'node:path'
|
|
|
11
11
|
* Opaque kernel test IDs remain valid without fences but cannot prove non-overlap.
|
|
12
12
|
* @param {string} resourceId */
|
|
13
13
|
function canonicalResource(resourceId) {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
if (typeof resourceId !== 'string' || resourceId.includes('\0')) return false
|
|
15
|
+
// Shape check only, NOT filesystem proof. The POSIX arm is every POSIX row
|
|
16
|
+
// plus the platform-neutral synthetic ids historical records may carry; the
|
|
17
|
+
// win32 arm is the NTFS-native shape the resource-identity win32 row
|
|
18
|
+
// produces. Real producers emit exactly one shape per platform; relative,
|
|
19
|
+
// root, trailing-slash and non-normalized spellings stay refused.
|
|
20
|
+
const posixCanonical = resourceId.startsWith('/') && resourceId !== '/' &&
|
|
16
21
|
!resourceId.endsWith('/') && posix.normalize(resourceId) === resourceId
|
|
22
|
+
if (process.platform !== 'win32') return posixCanonical
|
|
23
|
+
return posixCanonical ||
|
|
24
|
+
(/^[A-Za-z]:\\/.test(resourceId) && resourceId.length > 3 &&
|
|
25
|
+
!resourceId.endsWith('\\') && !resourceId.includes('/') &&
|
|
26
|
+
win32.normalize(resourceId) === resourceId)
|
|
17
27
|
}
|
|
18
28
|
|
|
19
29
|
/** Pure denial check. Historical closeout assertions grant no authority.
|
|
@@ -100,7 +100,7 @@ export function validateOperations(state) {
|
|
|
100
100
|
if (op.binding.target.kind === 'update') valid(op.outcome.resourceId === op.binding.target.resourceId && op.outcome.generation === op.binding.target.generation, 'update outcome binding')
|
|
101
101
|
if (op.phase === 'created' && op.fence?.kind === 'subtree') {
|
|
102
102
|
const suffix = relative(op.fence.ancestor, op.outcome.resourceId)
|
|
103
|
-
valid(suffix.length > 0 && suffix !== '..' && !suffix.startsWith('../') && !isAbsolute(suffix), 'created containment')
|
|
103
|
+
valid(suffix.length > 0 && suffix !== '..' && !suffix.startsWith('../') && !suffix.startsWith('..\\') && !isAbsolute(suffix), 'created containment')
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
}
|
|
@@ -137,7 +137,7 @@ function validateFence(op) {
|
|
|
137
137
|
else {
|
|
138
138
|
valid(fence.basis === 'conservative-ancestor', 'scope basis')
|
|
139
139
|
const suffix = relative(fence.ancestor, target.ancestor)
|
|
140
|
-
valid(suffix !== '..' && !suffix.startsWith('../') && !isAbsolute(suffix), 'conservative containment')
|
|
140
|
+
valid(suffix !== '..' && !suffix.startsWith('../') && !suffix.startsWith('..\\') && !isAbsolute(suffix), 'conservative containment')
|
|
141
141
|
}
|
|
142
142
|
} else {
|
|
143
143
|
shape(fence, ['kind', 'basis'])
|
|
@@ -72,8 +72,11 @@ export function readSnapshotBytes(authorityDir) {
|
|
|
72
72
|
return { presence: 'no-snapshot' }
|
|
73
73
|
}
|
|
74
74
|
if (!leaf.isFile() || leaf.isSymbolicLink()) return { presence: 'not-a-file' }
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
// Windows exposes no O_NOFOLLOW/O_NONBLOCK (constants undefined): the leaf
|
|
76
|
+
// lstat above already refused a symlink and the post-open fstat identity
|
|
77
|
+
// check below bounds the swap window (symlink creation needs privilege on
|
|
78
|
+
// Windows; the residual TOCTOU gap is documented in edit-lock.md).
|
|
79
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
77
80
|
const opened = fstatSync(fd)
|
|
78
81
|
if (!opened.isFile() || !sameNode(leaf, opened)) throw new Error('opened snapshot identity changed')
|
|
79
82
|
verifyPins(parents)
|
package/src/edit-lock/remote.js
CHANGED
|
@@ -7,17 +7,29 @@ import { createEditLockPeer } from './peer.js'
|
|
|
7
7
|
import { serveEditLockPeer } from './peer-transport.js'
|
|
8
8
|
import { createEditLockPeerClient } from './peer-client.js'
|
|
9
9
|
|
|
10
|
-
/** Deterministic per-user endpoint for one canonical authority directory.
|
|
11
|
-
* OS temp directory is user-private
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
|
|
10
|
+
/** Deterministic per-user endpoint for one canonical authority directory. On
|
|
11
|
+
* POSIX hosts the OS temp directory is user-private and the endpoint is a
|
|
12
|
+
* domain-socket file; on Windows it is a named pipe (a kernel object with no
|
|
13
|
+
* filesystem node, so there is no stale-node sweep and no 100-byte path
|
|
14
|
+
* budget — only the 256-char pipe-name limit) whose name carries no
|
|
15
|
+
* backslashes beyond the \\.\pipe\ prefix (verified on Node v24). Any
|
|
16
|
+
* same-user process that can reach the endpoint is inside the cooperative
|
|
17
|
+
* trust boundary, not attested.
|
|
18
|
+
* @param {string} directory @param {string} [platform] */
|
|
19
|
+
export function endpointFor(directory, platform = process.platform) {
|
|
15
20
|
const authority = realpathSync.native(directory)
|
|
16
|
-
const
|
|
21
|
+
const hash = createHash('sha256').update(authority).digest('hex').slice(0, 24)
|
|
22
|
+
if (platform === 'win32') return `\\\\.\\pipe\\weir-edit-lock-${hash}`
|
|
23
|
+
const path = join(tmpdir(), `weir-edit-lock-${hash}.sock`)
|
|
17
24
|
if (Buffer.byteLength(path) > 100) throw new Error(`edit lock endpoint path too long: ${path}`)
|
|
18
25
|
return path
|
|
19
26
|
}
|
|
20
27
|
|
|
28
|
+
/** True for Windows named-pipe endpoints: no filesystem node exists, so the
|
|
29
|
+
* stale-socket sweep and the close-time unlink are POSIX-only concerns.
|
|
30
|
+
* @param {string} endpoint */
|
|
31
|
+
const namedPipe = endpoint => endpoint.startsWith('\\\\.\\pipe\\') || endpoint.startsWith('\\\\?\\pipe\\')
|
|
32
|
+
|
|
21
33
|
/** Publisher side: one session channel per connection. The first frame must be
|
|
22
34
|
* `open {sessionId}`; the channel agent is fixed afterwards and EOF revokes it.
|
|
23
35
|
* Call only while holding the publisher reservation (stale socket removal).
|
|
@@ -25,10 +37,12 @@ export function endpointFor(directory) {
|
|
|
25
37
|
* @param {string} endpoint
|
|
26
38
|
* @param {WeakMap<object, (event: unknown) => void>} sinks remote agent → notice sink */
|
|
27
39
|
export async function serveEditLockEndpoint(lifecycle, endpoint, sinks) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
40
|
+
if (!namedPipe(endpoint)) {
|
|
41
|
+
try {
|
|
42
|
+
if (!lstatSync(endpoint).isSocket()) throw new Error(`unexpected node at edit lock endpoint ${endpoint}`)
|
|
43
|
+
unlinkSync(endpoint)
|
|
44
|
+
} catch (error) { if (/** @type {any} */ (error)?.code !== 'ENOENT') throw error }
|
|
45
|
+
}
|
|
32
46
|
/** @type {Set<{close: () => Promise<unknown>}>} */
|
|
33
47
|
const channels = new Set()
|
|
34
48
|
let closed = false
|
|
@@ -63,7 +77,9 @@ export async function serveEditLockEndpoint(lifecycle, endpoint, sinks) {
|
|
|
63
77
|
closed = true
|
|
64
78
|
await new Promise(resolve => server.close(() => resolve(undefined)))
|
|
65
79
|
const results = await Promise.allSettled([...channels].map(channel => channel.close()))
|
|
66
|
-
|
|
80
|
+
if (!namedPipe(endpoint)) {
|
|
81
|
+
try { unlinkSync(endpoint) } catch { /* already gone */ }
|
|
82
|
+
}
|
|
67
83
|
const errors = results.filter(result => result.status === 'rejected').map(result => /** @type {any} */ (result).reason)
|
|
68
84
|
if (errors.length) throw new AggregateError(errors, 'edit lock endpoint revocation failed')
|
|
69
85
|
},
|
|
@@ -48,8 +48,12 @@ function writeOwner(path, doc) {
|
|
|
48
48
|
renameSync(temporary, join(path, OWNER_FILE))
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// fsync on a directory handle is a POSIX crash-durability idiom: on Windows
|
|
52
|
+
// the handle opens but fsync fails EPERM (verified on Node v24, NTFS), so the
|
|
53
|
+
// win32 row skips it — the owner temp+rename stands on NTFS journaling.
|
|
51
54
|
/** @param {string} directory */
|
|
52
55
|
function syncParent(directory) {
|
|
56
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux') return
|
|
53
57
|
const handle = openSync(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0))
|
|
54
58
|
try { fsyncSync(handle) } finally { closeSync(handle) }
|
|
55
59
|
}
|
|
@@ -10,14 +10,23 @@ import { dirname, isAbsolute } from 'node:path'
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Read-only
|
|
13
|
+
* Read-only filesystem observations, not ownership or publication authority.
|
|
14
14
|
* Filesystem calls are not an atomic snapshot: callers must coordinate mutation.
|
|
15
15
|
* dev/ino continuity cannot detect inode reuse or change-and-restore (ABA).
|
|
16
|
+
*
|
|
17
|
+
* Verified platform rows: darwin/linux (POSIX spellings) and win32
|
|
18
|
+
* (openspec windows-platform-adaptation: drive-letter absolute spellings with
|
|
19
|
+
* both separators normalized to '/'; NTFS dev/ino/nlink verified real on Node
|
|
20
|
+
* v24; junctions are reparse points and surface as symbolic links). UNC and
|
|
21
|
+
* device paths are rejected on win32 — network shares are outside the
|
|
22
|
+
* supported envelope (edit-lock.md).
|
|
23
|
+
* @param {{ platform?: string }} [options]
|
|
16
24
|
*/
|
|
17
|
-
export function createResourceIdentity() {
|
|
18
|
-
if (
|
|
19
|
-
throw new Error(`unsupported platform: ${
|
|
25
|
+
export function createResourceIdentity({ platform = process.platform } = {}) {
|
|
26
|
+
if (platform !== 'darwin' && platform !== 'linux' && platform !== 'win32') {
|
|
27
|
+
throw new Error(`unsupported platform: ${platform}`)
|
|
20
28
|
}
|
|
29
|
+
const win = platform === 'win32'
|
|
21
30
|
/** @type {WeakMap<object, Evidence>} */
|
|
22
31
|
const observations = new WeakMap()
|
|
23
32
|
/** @param {string} filePath @param {{ cwd: string }} options */
|
|
@@ -25,7 +34,8 @@ export function createResourceIdentity() {
|
|
|
25
34
|
if (typeof cwd !== 'string' || !isAbsolute(cwd)) throw new TypeError('expected absolute cwd')
|
|
26
35
|
if (typeof filePath !== 'string' || filePath.length === 0) throw new TypeError('expected non-empty path')
|
|
27
36
|
if (filePath.includes('\0') || cwd.includes('\0')) throw new TypeError('NUL in path')
|
|
28
|
-
const
|
|
37
|
+
const joined = isAbsolute(filePath) ? filePath : `${cwd}/${filePath}`
|
|
38
|
+
const result = inspect(win ? normalizeWin(joined) : joined, win)
|
|
29
39
|
const observation = Object.freeze(result.value)
|
|
30
40
|
observations.set(observation, { filePath, cwd, kind: observation.kind, witnesses: result.witnesses, links: result.links })
|
|
31
41
|
return observation
|
|
@@ -48,11 +58,22 @@ export function createResourceIdentity() {
|
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
/** Canonical spelling for the win32 walk: single forward slashes under a
|
|
62
|
+
* drive-letter root. UNC/device roots are rejected, never reinterpreted.
|
|
63
|
+
* @param {string} spelling */
|
|
64
|
+
function normalizeWin(spelling) {
|
|
65
|
+
const normalized = spelling.replace(/\\/g, '/').replace(/\/{2,}/g, '/')
|
|
66
|
+
if (!/^[A-Za-z]:\//.test(normalized)) throw new Error(`unsupported path root (drive-letter absolute paths only): ${spelling.slice(0, 64)}`)
|
|
67
|
+
return normalized
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
/** Walk original prefixes without lexically collapsing symlink/.. or missing/..
|
|
52
|
-
*
|
|
53
|
-
|
|
71
|
+
* The ancestor chain terminates at the filesystem root of the spelling —
|
|
72
|
+
* '/' on POSIX, '<drive>:/' on win32 — detected as dirname's fixed point.
|
|
73
|
+
* @param {string} spelling @param {boolean} win @returns {Inspection} */
|
|
74
|
+
function inspect(spelling, win) {
|
|
54
75
|
const parts = spelling.split('/')
|
|
55
|
-
let ancestor = '/'
|
|
76
|
+
let ancestor = win ? /** @type {string} */ (parts[0]) : '/'
|
|
56
77
|
const witnesses = []
|
|
57
78
|
let links = 0
|
|
58
79
|
for (let index = 1; index < parts.length; index++) {
|
|
@@ -71,11 +92,11 @@ function inspect(spelling) {
|
|
|
71
92
|
ancestor = realpathSync.native(prefix)
|
|
72
93
|
const node = statSync(ancestor, { bigint: true })
|
|
73
94
|
witnesses.push(`${ancestor}:${node.dev}:${node.ino}:${entry.dev}:${entry.ino}`)
|
|
74
|
-
if (entry.isSymbolicLink()) traceLink(physical, witnesses, 0)
|
|
95
|
+
if (entry.isSymbolicLink()) traceLink(physical, witnesses, 0, win)
|
|
75
96
|
for (let parent = dirname(ancestor); ; parent = dirname(parent)) {
|
|
76
97
|
const directory = statSync(parent, { bigint: true })
|
|
77
98
|
witnesses.push(`${parent}:${directory.dev}:${directory.ino}`)
|
|
78
|
-
if (parent ===
|
|
99
|
+
if (parent === dirname(parent)) break
|
|
79
100
|
}
|
|
80
101
|
if (index < parts.length - 1) {
|
|
81
102
|
if (!node.isDirectory()) throw new Error('non-directory ancestor')
|
|
@@ -90,18 +111,20 @@ function inspect(spelling) {
|
|
|
90
111
|
* the physical cursor avoids recursively replaying the original parent spelling.
|
|
91
112
|
* Resolve each component only AFTER tracing it: lexical .. collapse or resolving
|
|
92
113
|
* the whole target up front would hide nested links and their topology changes.
|
|
93
|
-
*
|
|
94
|
-
|
|
114
|
+
* On win32 readlink returns backslash spellings; they are normalized before the
|
|
115
|
+
* walk so component splitting matches the POSIX row.
|
|
116
|
+
* @param {string} path @param {string[]} witnesses @param {number} depth @param {boolean} win */
|
|
117
|
+
function traceLink(path, witnesses, depth, win) {
|
|
95
118
|
if (depth >= 40) throw new Error('unsupported symlink depth')
|
|
96
|
-
const target = readlinkSync(path)
|
|
119
|
+
const target = win ? readlinkSync(path).replace(/\\/g, '/') : readlinkSync(path)
|
|
97
120
|
witnesses.push(`link:${path}:${target}`)
|
|
98
|
-
let parent = isAbsolute(target) ? '/' : dirname(path)
|
|
121
|
+
let parent = isAbsolute(target) ? (win ? /** @type {string} */ (target.split('/')[0]) : '/') : dirname(path)
|
|
99
122
|
const parts = isAbsolute(target) ? target.split('/').slice(1) : target.split('/')
|
|
100
123
|
for (const part of parts) {
|
|
101
124
|
const prefix = `${parent}/${part}`
|
|
102
125
|
const node = lstatSync(prefix, { bigint: true })
|
|
103
126
|
witnesses.push(`${prefix}:${node.dev}:${node.ino}`)
|
|
104
|
-
if (node.isSymbolicLink()) traceLink(prefix, witnesses, depth + 1)
|
|
127
|
+
if (node.isSymbolicLink()) traceLink(prefix, witnesses, depth + 1, win)
|
|
105
128
|
parent = realpathSync.native(prefix)
|
|
106
129
|
}
|
|
107
130
|
}
|
package/src/edit-lock/runtime.js
CHANGED
|
@@ -17,6 +17,9 @@ async function writeAuthorityFile(directory, file, bytes) {
|
|
|
17
17
|
const handle = await open(temporary, 'wx', 0o600)
|
|
18
18
|
try { await handle.writeFile(bytes); await handle.sync() } finally { await handle.close() }
|
|
19
19
|
await rename(temporary, join(directory, file))
|
|
20
|
+
// Directory fsync is POSIX-only (EPERM on win32): the file fsync above and
|
|
21
|
+
// the atomic rename stand alone there (reservation.js SYNC_SUPPORTED stance).
|
|
22
|
+
if (process.platform !== 'darwin' && process.platform !== 'linux') return
|
|
20
23
|
const dir = await open(directory, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW)
|
|
21
24
|
try { await dir.sync() } finally { await dir.close() }
|
|
22
25
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// the scheduler's job, clock and timer are injected so the composition and
|
|
4
4
|
// the unit tests share one core.
|
|
5
5
|
import { lstatSync } from 'node:fs'
|
|
6
|
+
import { dirname } from 'node:path'
|
|
6
7
|
|
|
7
8
|
/** Missing-target classification for one stored canonical resource id. ONLY
|
|
8
9
|
* ENOENT counts: a successful lstat — including of a dangling symlink — is
|
|
@@ -16,7 +17,23 @@ export function isMissingTarget(resourceId) {
|
|
|
16
17
|
lstatSync(resourceId)
|
|
17
18
|
return false
|
|
18
19
|
} catch (error) {
|
|
19
|
-
|
|
20
|
+
const code = /** @type {any} */ (error)?.code
|
|
21
|
+
if (code !== 'ENOENT') return false
|
|
22
|
+
// Windows reports a path through a regular-file component as ENOENT as
|
|
23
|
+
// well (there is no ENOTDIR): preserve the conservative POSIX
|
|
24
|
+
// classification by walking up — a non-directory ancestor means the path
|
|
25
|
+
// is BLOCKED (skip the row), not missing.
|
|
26
|
+
if (process.platform === 'win32') {
|
|
27
|
+
for (let parent = dirname(resourceId); parent !== dirname(parent); parent = dirname(parent)) {
|
|
28
|
+
let stat
|
|
29
|
+
try { stat = lstatSync(parent) } catch (ancestorError) {
|
|
30
|
+
if (/** @type {any} */ (ancestorError)?.code === 'ENOENT') continue
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
return stat.isDirectory()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return true
|
|
20
37
|
}
|
|
21
38
|
}
|
|
22
39
|
|
package/src/edit-lock/store.js
CHANGED
|
@@ -6,6 +6,11 @@ import { validateOperationTransitions } from './operation-history.js'
|
|
|
6
6
|
import { canonical, parseSnapshot, validateImage } from './snapshot.js'
|
|
7
7
|
import { validateAdminTransition } from './admin-ledger.js'
|
|
8
8
|
|
|
9
|
+
// Directory fsync is POSIX-only (EPERM on win32, Node v24 — same stance as
|
|
10
|
+
// reservation.js SYNC_SUPPORTED): the win32 row persists with file fsync +
|
|
11
|
+
// atomic rename and skips the directory sync steps.
|
|
12
|
+
const DIR_SYNC_SUPPORTED = process.platform === 'darwin' || process.platform === 'linux'
|
|
13
|
+
|
|
9
14
|
/**
|
|
10
15
|
* Unmounted historical Edit Lock snapshot storage, NOT a kernel restore interface.
|
|
11
16
|
* The caller MUST already hold an externally proven exclusive directory lifecycle
|
|
@@ -79,11 +84,14 @@ export async function openEditLockStore({ directory, domainId, mode, maxSnapshot
|
|
|
79
84
|
await io('file-sync', async () => { await openedFile.sync() })
|
|
80
85
|
await io('file-close', async () => { await openedFile.close(); file = undefined })
|
|
81
86
|
await io('rename', async () => { renameStarted = true; await rename(temporary, target) })
|
|
82
|
-
|
|
83
|
-
|
|
87
|
+
// The directory steps fire on every platform — checkpoint cadence is a
|
|
88
|
+
// contract; only the real directory fsync is POSIX-only (EPERM on win32,
|
|
89
|
+
// same stance as reservation.js SYNC_SUPPORTED).
|
|
90
|
+
await io('directory-open', async () => { dir = DIR_SYNC_SUPPORTED ? await open(directory, constants.O_RDONLY | (constants.O_DIRECTORY ?? 0) | (constants.O_NOFOLLOW ?? 0)) : undefined })
|
|
91
|
+
if (DIR_SYNC_SUPPORTED) valid(dir, 'opened directory descriptor')
|
|
84
92
|
const openedDirectory = dir
|
|
85
|
-
await io('directory-sync', async () => { await openedDirectory
|
|
86
|
-
await io('directory-close', async () => { await openedDirectory
|
|
93
|
+
await io('directory-sync', async () => { await openedDirectory?.sync() })
|
|
94
|
+
await io('directory-close', async () => { await openedDirectory?.close(); dir = undefined })
|
|
87
95
|
committedBytes = Buffer.from(bytes, 'utf8')
|
|
88
96
|
} catch (cause) {
|
|
89
97
|
// Never remove/promote a temp or roll back a rename. Once rename was
|
|
@@ -108,7 +116,9 @@ export async function openEditLockStore({ directory, domainId, mode, maxSnapshot
|
|
|
108
116
|
await persist(current)
|
|
109
117
|
} else {
|
|
110
118
|
valid((await lstat(target)).isFile(), 'snapshot must be regular file')
|
|
111
|
-
|
|
119
|
+
// win32: O_NOFOLLOW/O_NONBLOCK are undefined — the lstat above and the post-open
|
|
120
|
+
// stat below carry the identity checks (same stance as read-authority.js).
|
|
121
|
+
const file = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0))
|
|
112
122
|
try {
|
|
113
123
|
valid((await file.stat()).isFile(), 'snapshot must be regular file')
|
|
114
124
|
let bytes
|