| 1 | export type ResourceClaims = { |
| 2 | read?: readonly string[] |
| 3 | write?: readonly string[] |
| 4 | } |
| 5 | |
| 6 | export type NormalizedResourceClaims = { |
| 7 | read: readonly string[] |
| 8 | write: readonly string[] |
| 9 | } |
| 10 | |
| 11 | export type ResourceLockWaitPolicy = 'block' | 'fail' |
| 12 | |
| 13 | export type ResourceLockAcquireOptions = { |
| 14 | ownerToken: symbol |
| 15 | signal?: AbortSignal |
| 16 | wait: ResourceLockWaitPolicy |
| 17 | } |
| 18 | |
| 19 | export type ReleaseFunc = () => void |
| 20 | |
| 21 | type Waiter = { |
| 22 | claims: NormalizedResourceClaims |
| 23 | ownerToken: symbol |
| 24 | resolve: (release: ReleaseFunc) => void |
| 25 | reject: (error: Error) => void |
| 26 | removeAbortListener: () => void |
| 27 | } |
| 28 | |
| 29 | const normalizeKeys = (keys: readonly string[] | undefined): string[] => { |
| 30 | const normalized = new Set<string>() |
| 31 | for (const key of keys || []) { |
| 32 | const value = key.trim() |
| 33 | if (!value) throw new Error('Resource claim key must not be empty') |
| 34 | normalized.add(value) |
| 35 | } |
| 36 | return [...normalized] |
| 37 | } |
| 38 | |
| 39 | export const normalizeResourceClaims = (claims: ResourceClaims): NormalizedResourceClaims => { |
| 40 | const write = normalizeKeys(claims.write) |
| 41 | const writeKeys = new Set(write) |
| 42 | const read = normalizeKeys(claims.read).filter((key) => !writeKeys.has(key)) |
| 43 | return { read, write } |
| 44 | } |
| 45 | |
| 46 | const intersects = (left: readonly string[], right: readonly string[]): boolean => { |
| 47 | if (left.length === 0 || right.length === 0) return false |
| 48 | const rightKeys = new Set(right) |
| 49 | return left.some((key) => rightKeys.has(key)) |
| 50 | } |
| 51 | |
| 52 | export const resourceClaimsConflict = (left: ResourceClaims, right: ResourceClaims): boolean => { |
| 53 | const normalizedLeft = normalizeResourceClaims(left) |
| 54 | const normalizedRight = normalizeResourceClaims(right) |
| 55 | return ( |
| 56 | intersects(normalizedLeft.write, normalizedRight.write) || |
| 57 | intersects(normalizedLeft.write, normalizedRight.read) || |
| 58 | intersects(normalizedLeft.read, normalizedRight.write) |
| 59 | ) |
| 60 | } |
| 61 | |
| 62 | export const createAbortError = (): Error => { |
| 63 | const error = new Error('The operation was aborted') |
| 64 | error.name = 'AbortError' |
| 65 | return error |
| 66 | } |
| 67 | |
| 68 | /** |
| 69 | * Reader/writer lock for complete resource claim sets. A waiter either receives every requested |
| 70 | * key or none of them; there is intentionally no public partial-acquire API. |
| 71 | */ |
| 72 | export class ResourceLock { |
| 73 | private readonly readersByKey = new Map<string, Set<symbol>>() |
| 74 | private readonly writerByKey = new Map<string, symbol>() |
| 75 | private readonly ownerStates = new Map<symbol, 'waiting' | 'active'>() |
| 76 | private readonly waiters: Waiter[] = [] |
| 77 | |
| 78 | async acquire( |
| 79 | claims: ResourceClaims, |
| 80 | options: ResourceLockAcquireOptions |
| 81 | ): Promise<ReleaseFunc | null> { |
| 82 | const normalizedClaims = normalizeResourceClaims(claims) |
| 83 | if (options.signal?.aborted) throw createAbortError() |
| 84 | if (this.ownerStates.has(options.ownerToken)) { |
| 85 | throw new Error('ResourceLock does not support nested claims for the same owner') |
| 86 | } |
| 87 | |
| 88 | if (options.wait === 'fail') { |
| 89 | if (this.hasActiveConflict(normalizedClaims) || this.hasWaitingConflict(normalizedClaims)) { |
| 90 | return null |
| 91 | } |
| 92 | return this.grant(normalizedClaims, options.ownerToken) |
| 93 | } |
| 94 | |
| 95 | return new Promise<ReleaseFunc>((resolve, reject) => { |
| 96 | let waiter: Waiter |
| 97 | const removeAbortListener = (): void => { |
| 98 | options.signal?.removeEventListener('abort', onAbort) |
| 99 | } |
| 100 | const onAbort = (): void => { |
| 101 | const waiterIndex = this.waiters.indexOf(waiter) |
| 102 | if (waiterIndex === -1) return |
| 103 | this.waiters.splice(waiterIndex, 1) |
| 104 | this.ownerStates.delete(options.ownerToken) |
| 105 | removeAbortListener() |
| 106 | reject(createAbortError()) |
| 107 | this.drain() |
| 108 | } |
| 109 | |
| 110 | waiter = { |
| 111 | claims: normalizedClaims, |
| 112 | ownerToken: options.ownerToken, |
| 113 | resolve, |
| 114 | reject, |
| 115 | removeAbortListener |
| 116 | } |
| 117 | this.ownerStates.set(options.ownerToken, 'waiting') |
| 118 | this.waiters.push(waiter) |
| 119 | options.signal?.addEventListener('abort', onAbort, { once: true }) |
| 120 | this.drain() |
| 121 | }) |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * Immediate all-or-nothing acquisition used by the compatibility adapter |
| 126 | * while legacy IPC handlers still expose synchronous busy responses. |
| 127 | */ |
| 128 | tryAcquire( |
| 129 | claims: ResourceClaims, |
| 130 | options: Omit<ResourceLockAcquireOptions, 'wait'> |
| 131 | ): ReleaseFunc | null { |
| 132 | const normalizedClaims = normalizeResourceClaims(claims) |
| 133 | if (options.signal?.aborted) throw createAbortError() |
| 134 | if (this.ownerStates.has(options.ownerToken)) { |
| 135 | throw new Error('ResourceLock does not support nested claims for the same owner') |
| 136 | } |
| 137 | if (this.hasActiveConflict(normalizedClaims) || this.hasWaitingConflict(normalizedClaims)) { |
| 138 | return null |
| 139 | } |
| 140 | return this.grant(normalizedClaims, options.ownerToken) |
| 141 | } |
| 142 | |
| 143 | private hasActiveConflict(claims: NormalizedResourceClaims): boolean { |
| 144 | return ( |
| 145 | claims.write.some( |
| 146 | (key) => this.writerByKey.has(key) || (this.readersByKey.get(key)?.size || 0) > 0 |
| 147 | ) || claims.read.some((key) => this.writerByKey.has(key)) |
| 148 | ) |
| 149 | } |
| 150 | |
| 151 | private hasWaitingConflict(claims: NormalizedResourceClaims): boolean { |
| 152 | return this.waiters.some((waiter) => resourceClaimsConflict(claims, waiter.claims)) |
| 153 | } |
| 154 | |
| 155 | private drain(): void { |
| 156 | const earlierBlockedClaims: NormalizedResourceClaims[] = [] |
| 157 | |
| 158 | for (let index = 0; index < this.waiters.length; ) { |
| 159 | const waiter = this.waiters[index] |
| 160 | const blockedByActive = this.hasActiveConflict(waiter.claims) |
| 161 | const blockedByEarlierWaiter = earlierBlockedClaims.some((claims) => |
| 162 | resourceClaimsConflict(waiter.claims, claims) |
| 163 | ) |
| 164 | |
| 165 | if (blockedByActive || blockedByEarlierWaiter) { |
| 166 | earlierBlockedClaims.push(waiter.claims) |
| 167 | index += 1 |
| 168 | continue |
| 169 | } |
| 170 | |
| 171 | this.waiters.splice(index, 1) |
| 172 | waiter.removeAbortListener() |
| 173 | waiter.resolve(this.grant(waiter.claims, waiter.ownerToken)) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | private grant(claims: NormalizedResourceClaims, ownerToken: symbol): ReleaseFunc { |
| 178 | this.ownerStates.set(ownerToken, 'active') |
| 179 | for (const key of claims.write) { |
| 180 | this.writerByKey.set(key, ownerToken) |
| 181 | } |
| 182 | for (const key of claims.read) { |
| 183 | const readers = this.readersByKey.get(key) || new Set<symbol>() |
| 184 | readers.add(ownerToken) |
| 185 | this.readersByKey.set(key, readers) |
| 186 | } |
| 187 | |
| 188 | let released = false |
| 189 | return () => { |
| 190 | if (released) return |
| 191 | released = true |
| 192 | |
| 193 | for (const key of claims.write) { |
| 194 | if (this.writerByKey.get(key) === ownerToken) this.writerByKey.delete(key) |
| 195 | } |
| 196 | for (const key of claims.read) { |
| 197 | const readers = this.readersByKey.get(key) |
| 198 | if (!readers) continue |
| 199 | readers.delete(ownerToken) |
| 200 | if (readers.size === 0) this.readersByKey.delete(key) |
| 201 | } |
| 202 | this.ownerStates.delete(ownerToken) |
| 203 | this.drain() |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 |