diff --git a/src/main/lease-registry.ts b/src/main/lease-registry.ts new file mode 100644 index 0000000..bace5a4 --- /dev/null +++ b/src/main/lease-registry.ts @@ -0,0 +1,431 @@ +import { EventEmitter } from 'events'; + +// Lease registry — ownership arbitration for audio scopes (plan: docs/audio-ownership-plan.md §2/§8). +// +// Two primitives: +// - exclusive leases (signal-chain:, device-config, playback, ...) +// - refcounted demands (capture, detection:) — additive intent, +// the engine acts while the holder count > 0. +// +// The registry never derives identity itself: the IPC wiring derives holder +// ids from the sender (webContents id + attributed plugin id, plan §9) and +// main-process callers use the WELL_KNOWN_HOLDERS enum (§8.4). Native stays +// dumb — per-route chainGeneration remains the tamper-evident layer below. + +export type HolderId = string; + +export const WELL_KNOWN_HOLDERS = Object.freeze({ + backingPlayer: 'engine:backing-player', + startupRestore: 'main:startup-restore', + executor: 'main:executor', + deviceScreen: 'main:device-screen', +}); + +const WELL_KNOWN_SET = new Set(Object.values(WELL_KNOWN_HOLDERS)); + +export type LeaseScope = string; + +const EXCLUSIVE_SCOPE_RE = /^(device-config|playback|monitor-state|signal-chain:[\w.-]+|mixer-channel:[\w.-]+)$/; +const DEMAND_SCOPE_RE = /^(capture|detection:[\w.-]+)$/; + +export type AcquireResult = + | { granted: true; scope: LeaseScope; holderId: HolderId } + | { granted: false; scope: LeaseScope; reason: AcquireRefusal; holderId: HolderId | null }; + +export type AcquireRefusal = 'held' | 'invalid-scope' | 'invalid-holder' | 'revoking'; + +export type ReleaseReason = 'released' | 'revoked' | 'holder-destroyed' | 'grace-expired'; + +type Lease = { + holderId: HolderId; + acquiredAt: number; + // Set while a takeover drain is running: refuses acquires and the old + // holder's further writes until the drain completes (§8.1). + revoking: boolean; + overrides: Map; +}; + +type Demand = { + // Idempotent per holder: one holder counts once no matter how many times + // it asks (the five hand-rolled "did *I* start it?" hacks collapse here). + holders: Map; + // User raw-stop suspends demands instead of clearing them (§8.3); only a + // user start resumes. + suspended: boolean; +}; + +type GraceEntry = { + identityKey: string; + leases: Map; + demands: Map; + timer: NodeJS.Timeout; + expiresAt: number; +}; + +export type LeaseRegistryOptions = { + graceMs?: number; + now?: () => number; +}; + +export type LeaseSnapshotEntry = { + scope: LeaseScope; + holderId: HolderId; + heldMs: number; + revoking: boolean; + overrides: string[]; +}; + +export type DemandSnapshotEntry = { + scope: LeaseScope; + suspended: boolean; + holders: Array<{ holderId: HolderId; heldMs: number }>; +}; + +export function isValidExclusiveScope(scope: string): boolean { + return EXCLUSIVE_SCOPE_RE.test(scope); +} + +export function isValidDemandScope(scope: string): boolean { + return DEMAND_SCOPE_RE.test(scope); +} + +export function isWellKnownHolder(holderId: string): boolean { + return WELL_KNOWN_SET.has(holderId); +} + +function isValidHolder(holderId: unknown): holderId is HolderId { + if (typeof holderId !== 'string' || holderId.length === 0 || holderId.length > 256) return false; + if (WELL_KNOWN_SET.has(holderId)) return true; + // Derived renderer identity: "wc:" with optional attributed + // plugin suffix "#" (soft part, plan §9). + return /^wc:\d+(#[\w.-]{1,128})?$/.test(holderId); +} + +export class LeaseRegistry extends EventEmitter { + private leases = new Map(); + private demands = new Map(); + private baseValues = new Map>(); + private graces = new Map(); + private graceMs: number; + private now: () => number; + + constructor(options: LeaseRegistryOptions = {}) { + super(); + this.graceMs = options.graceMs ?? 8000; + this.now = options.now ?? Date.now; + } + + // ---- exclusive leases ------------------------------------------------- + + acquire(scope: LeaseScope, holderId: HolderId): AcquireResult { + if (!isValidExclusiveScope(scope)) { + return this.refuse(scope, 'invalid-scope', null); + } + if (!isValidHolder(holderId)) { + return this.refuse(scope, 'invalid-holder', null); + } + const existing = this.leases.get(scope); + if (existing) { + if (existing.holderId === holderId) { + return { granted: true, scope, holderId }; + } + return this.refuse(scope, existing.revoking ? 'revoking' : 'held', existing.holderId); + } + this.leases.set(scope, { holderId, acquiredAt: this.now(), revoking: false, overrides: new Map() }); + this.emit('lease-granted', { scope, holderId }); + return { granted: true, scope, holderId }; + } + + release(scope: LeaseScope, holderId: HolderId): boolean { + const lease = this.leases.get(scope); + if (!lease || lease.holderId !== holderId) return false; + this.dropLease(scope, lease, 'released'); + return true; + } + + getHolder(scope: LeaseScope): HolderId | null { + return this.leases.get(scope)?.holderId ?? null; + } + + // May the holder write to the scope right now? False while a takeover + // drain is in flight (the serializer stops accepting the old holder's + // ops the moment revocation starts, §8.1). + canWrite(scope: LeaseScope, holderId: HolderId): boolean { + const lease = this.leases.get(scope); + return !!lease && lease.holderId === holderId && !lease.revoking; + } + + // User-initiated takeover (§2 policy + §8.1 drain-then-grant). `drain` + // is supplied by the wiring layer (executor serializer drain); the new + // holder is granted only after it resolves. + async takeover(scope: LeaseScope, newHolderId: HolderId, drain?: () => Promise): Promise { + if (!isValidExclusiveScope(scope)) return this.refuse(scope, 'invalid-scope', null); + if (!isValidHolder(newHolderId)) return this.refuse(scope, 'invalid-holder', null); + const lease = this.leases.get(scope); + if (!lease) return this.acquire(scope, newHolderId); + if (lease.holderId === newHolderId) return { granted: true, scope, holderId: newHolderId }; + if (lease.revoking) return this.refuse(scope, 'revoking', lease.holderId); + + lease.revoking = true; + this.emit('lease-revoked', { scope, holderId: lease.holderId, takenOverBy: newHolderId }); + try { + if (drain) await drain(); + } finally { + // The old lease dies even if the drain failed — a wedged drain + // must not brick the scope (§2: never brick until restart). + this.dropLease(scope, lease, 'revoked'); + } + return this.acquire(scope, newHolderId); + } + + // ---- refcounted demands ------------------------------------------------ + + acquireDemand(scope: LeaseScope, holderId: HolderId): boolean { + if (!isValidDemandScope(scope) || !isValidHolder(holderId)) return false; + let demand = this.demands.get(scope); + if (!demand) { + demand = { holders: new Map(), suspended: false }; + this.demands.set(scope, demand); + } + if (!demand.holders.has(holderId)) { + demand.holders.set(holderId, { since: this.now() }); + this.emitDemandChanged(scope, demand); + } + return true; + } + + releaseDemand(scope: LeaseScope, holderId: HolderId): boolean { + const demand = this.demands.get(scope); + if (!demand || !demand.holders.delete(holderId)) return false; + this.emitDemandChanged(scope, demand); + return true; + } + + demandCount(scope: LeaseScope): number { + return this.demands.get(scope)?.holders.size ?? 0; + } + + // Effective demand: what the engine should act on. Zero while suspended. + demandActive(scope: LeaseScope): boolean { + const demand = this.demands.get(scope); + return !!demand && !demand.suspended && demand.holders.size > 0; + } + + // User raw-stop (§8.3): suspend every demand scope matching the prefix + // ('capture', or '' for all). Holders keep their registration. + suspendDemands(scopePrefix = ''): void { + for (const [scope, demand] of this.demands) { + if (!scope.startsWith(scopePrefix) || demand.suspended) continue; + demand.suspended = true; + this.emit('demand-suspended', { scope }); + this.emitDemandChanged(scope, demand); + } + } + + resumeDemands(scopePrefix = ''): void { + for (const [scope, demand] of this.demands) { + if (!scope.startsWith(scopePrefix) || !demand.suspended) continue; + demand.suspended = false; + this.emit('demand-resumed', { scope }); + this.emitDemandChanged(scope, demand); + } + } + + // ---- layered values (base + override, §2) ------------------------------- + + setBase(scope: LeaseScope, key: string, value: unknown): void { + let map = this.baseValues.get(scope); + if (!map) { + map = new Map(); + this.baseValues.set(scope, map); + } + map.set(key, value); + // Base writes are always accepted, even during an override (§8.10) — + // they persist and re-apply on release. + this.emitValueChanged(scope, key); + } + + setOverride(scope: LeaseScope, key: string, value: unknown, holderId: HolderId): boolean { + const lease = this.leases.get(scope); + if (!lease || lease.holderId !== holderId || lease.revoking) return false; + lease.overrides.set(key, value); + this.emitValueChanged(scope, key); + return true; + } + + getEffectiveValue(scope: LeaseScope, key: string): unknown { + const lease = this.leases.get(scope); + if (lease && lease.overrides.has(key)) return lease.overrides.get(key); + return this.baseValues.get(scope)?.get(key); + } + + hasOverride(scope: LeaseScope, key: string): boolean { + return this.leases.get(scope)?.overrides.has(key) ?? false; + } + + // ---- holder lifecycle ---------------------------------------------------- + + // webContents destroyed / plugin teardown: everything goes, immediately. + releaseHolder(holderId: HolderId): void { + this.cancelGrace(holderId); + for (const [scope, lease] of this.leases) { + if (lease.holderId === holderId) this.dropLease(scope, lease, 'holder-destroyed'); + } + for (const [scope, demand] of this.demands) { + if (demand.holders.delete(holderId)) this.emitDemandChanged(scope, demand); + } + } + + // webContents reload (§8.2): leases + demands enter a grace window keyed + // on manifest identity. The same identity re-requesting within the window + // restores them (possibly under a new webContents-derived holder id); + // expiry releases for real. + beginGrace(holderId: HolderId, identityKey: string): void { + this.cancelGrace(holderId); + const heldLeases = new Map(); + for (const [scope, lease] of this.leases) { + if (lease.holderId === holderId) { + heldLeases.set(scope, lease); + this.leases.delete(scope); + this.emit('lease-suspended', { scope, holderId, identityKey }); + } + } + const heldDemands = new Map(); + for (const [scope, demand] of this.demands) { + const entry = demand.holders.get(holderId); + if (entry) { + heldDemands.set(scope, entry); + demand.holders.delete(holderId); + this.emitDemandChanged(scope, demand); + } + } + if (heldLeases.size === 0 && heldDemands.size === 0) return; + const timer = setTimeout(() => this.expireGrace(holderId), this.graceMs); + timer.unref?.(); + this.graces.set(holderId, { + identityKey, + leases: heldLeases, + demands: heldDemands, + timer, + expiresAt: this.now() + this.graceMs, + }); + } + + // Reloaded identity comes back (same manifest identity, fresh holder id). + // Returns the scopes restored. + tryRestore(identityKey: string, newHolderId: HolderId): LeaseScope[] { + if (!isValidHolder(newHolderId)) return []; + const restored: LeaseScope[] = []; + for (const [oldHolderId, grace] of this.graces) { + if (grace.identityKey !== identityKey) continue; + clearTimeout(grace.timer); + this.graces.delete(oldHolderId); + for (const [scope, lease] of grace.leases) { + if (this.leases.has(scope)) continue; // grabbed meanwhile — refuse quietly, waiter won + lease.holderId = newHolderId; + lease.revoking = false; + this.leases.set(scope, lease); + restored.push(scope); + this.emit('lease-granted', { scope, holderId: newHolderId, restored: true }); + } + for (const [scope, entry] of grace.demands) { + let demand = this.demands.get(scope); + if (!demand) { + demand = { holders: new Map(), suspended: false }; + this.demands.set(scope, demand); + } + if (!demand.holders.has(newHolderId)) { + demand.holders.set(newHolderId, entry); + restored.push(scope); + this.emitDemandChanged(scope, demand); + } + } + } + return restored; + } + + // ---- diagnostics ---------------------------------------------------------- + + snapshot(): { leases: LeaseSnapshotEntry[]; demands: DemandSnapshotEntry[]; graces: Array<{ identityKey: string; expiresInMs: number }> } { + const now = this.now(); + return { + leases: [...this.leases.entries()].map(([scope, lease]) => ({ + scope, + holderId: lease.holderId, + heldMs: now - lease.acquiredAt, + revoking: lease.revoking, + overrides: [...lease.overrides.keys()], + })), + demands: [...this.demands.entries()] + .filter(([, demand]) => demand.holders.size > 0 || demand.suspended) + .map(([scope, demand]) => ({ + scope, + suspended: demand.suspended, + holders: [...demand.holders.entries()].map(([holderId, entry]) => ({ + holderId, + heldMs: now - entry.since, + })), + })), + graces: [...this.graces.values()].map(grace => ({ + identityKey: grace.identityKey, + expiresInMs: Math.max(0, grace.expiresAt - now), + })), + }; + } + + dispose(): void { + for (const grace of this.graces.values()) clearTimeout(grace.timer); + this.graces.clear(); + this.removeAllListeners(); + } + + // ---- internals -------------------------------------------------------------- + + private refuse(scope: LeaseScope, reason: AcquireRefusal, holderId: HolderId | null): AcquireResult { + this.emit('lease-refused', { scope, reason, holderId }); + return { granted: false, scope, reason, holderId }; + } + + private dropLease(scope: LeaseScope, lease: Lease, reason: ReleaseReason): void { + this.leases.delete(scope); + const clearedOverrides = [...lease.overrides.keys()]; + lease.overrides.clear(); + this.emit('lease-released', { scope, holderId: lease.holderId, reason }); + // Overrides die with the lease; the base value is effective again. + for (const key of clearedOverrides) this.emitValueChanged(scope, key); + } + + private expireGrace(holderId: HolderId): void { + const grace = this.graces.get(holderId); + if (!grace) return; + this.graces.delete(holderId); + for (const [scope, lease] of grace.leases) { + this.emit('lease-released', { scope, holderId: lease.holderId, reason: 'grace-expired' }); + for (const key of lease.overrides.keys()) this.emitValueChanged(scope, key); + } + // Demands parked in the grace were already removed from the counts. + } + + private cancelGrace(holderId: HolderId): void { + const grace = this.graces.get(holderId); + if (!grace) return; + clearTimeout(grace.timer); + this.graces.delete(holderId); + for (const [scope, lease] of grace.leases) { + this.emit('lease-released', { scope, holderId: lease.holderId, reason: 'holder-destroyed' }); + } + } + + private emitDemandChanged(scope: LeaseScope, demand: Demand): void { + this.emit('demand-changed', { + scope, + count: demand.holders.size, + active: !demand.suspended && demand.holders.size > 0, + suspended: demand.suspended, + }); + } + + private emitValueChanged(scope: LeaseScope, key: string): void { + this.emit('value-changed', { scope, key, value: this.getEffectiveValue(scope, key), overridden: this.hasOverride(scope, key) }); + } +} diff --git a/tests/lease-registry.test.js b/tests/lease-registry.test.js new file mode 100644 index 0000000..34bf28c --- /dev/null +++ b/tests/lease-registry.test.js @@ -0,0 +1,265 @@ +// Lease registry (docs/audio-ownership-plan.md §2/§8): exclusive leases, +// refcounted demands, drain-then-grant takeover, reload grace window, +// user-stop suspend semantics, holder-death matrix, layered values. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { loadTs } = require('./_load-ts'); + +const { LeaseRegistry, WELL_KNOWN_HOLDERS, isValidExclusiveScope, isValidDemandScope } = loadTs('src/main/lease-registry.ts'); + +const WC1 = 'wc:1#nam_tone'; +const WC2 = 'wc:2#rig_builder'; +const CHAIN = 'signal-chain:desktop-main'; + +function collect(registry, event) { + const seen = []; + registry.on(event, e => seen.push(e)); + return seen; +} + +test('scope validation', () => { + assert.ok(isValidExclusiveScope('device-config')); + assert.ok(isValidExclusiveScope('signal-chain:desktop-main')); + assert.ok(isValidExclusiveScope('mixer-channel:0')); + assert.ok(!isValidExclusiveScope('capture')); + assert.ok(!isValidExclusiveScope('signal-chain:')); + assert.ok(isValidDemandScope('capture')); + assert.ok(isValidDemandScope('detection:desktop-main')); + assert.ok(!isValidDemandScope('device-config')); +}); + +test('acquire / refuse / release / getHolder', () => { + const r = new LeaseRegistry(); + const refused = collect(r, 'lease-refused'); + + assert.equal(r.acquire(CHAIN, WC1).granted, true); + assert.equal(r.getHolder(CHAIN), WC1); + + // second caller refused, holder named (refusal-by-default policy) + const res = r.acquire(CHAIN, WC2); + assert.deepEqual(res, { granted: false, scope: CHAIN, reason: 'held', holderId: WC1 }); + assert.equal(refused.length, 1); + + // re-acquire by the same holder is idempotent + assert.equal(r.acquire(CHAIN, WC1).granted, true); + + // only the holder may release + assert.equal(r.release(CHAIN, WC2), false); + assert.equal(r.release(CHAIN, WC1), true); + assert.equal(r.getHolder(CHAIN), null); + assert.equal(r.acquire(CHAIN, WC2).granted, true); + r.dispose(); +}); + +test('invalid scopes and holders are refused, never thrown', () => { + const r = new LeaseRegistry(); + assert.equal(r.acquire('bogus-scope', WC1).reason, 'invalid-scope'); + assert.equal(r.acquire(CHAIN, 'i-declare-myself').reason, 'invalid-holder'); + assert.equal(r.acquire(CHAIN, '').reason, 'invalid-holder'); + assert.equal(r.acquireDemand('bogus', WC1), false); + assert.equal(r.acquireDemand('capture', 'nope nope'), false); + // well-known internal holders pass (§8.4) + assert.equal(r.acquire('device-config', WELL_KNOWN_HOLDERS.deviceScreen).granted, true); + r.dispose(); +}); + +test('takeover = drain-then-grant (§8.1)', async () => { + const r = new LeaseRegistry(); + const revoked = collect(r, 'lease-revoked'); + r.acquire(CHAIN, WC1); + + let drained = false; + let refusedDuringDrain = null; + let oldCanWriteDuringDrain = null; + const result = await r.takeover(CHAIN, WC2, async () => { + // during the drain: old holder can no longer write, new acquires refused as 'revoking' + oldCanWriteDuringDrain = r.canWrite(CHAIN, WC1); + refusedDuringDrain = r.acquire(CHAIN, 'wc:3').reason; + // revocation event fired at drain START so the old holder can stop enqueueing + assert.equal(revoked.length, 1); + assert.equal(revoked[0].takenOverBy, WC2); + drained = true; + }); + + assert.equal(drained, true); + assert.equal(oldCanWriteDuringDrain, false); + assert.equal(refusedDuringDrain, 'revoking'); + assert.equal(result.granted, true); + assert.equal(r.getHolder(CHAIN), WC2); + assert.equal(r.canWrite(CHAIN, WC2), true); + r.dispose(); +}); + +test('takeover grants even when the drain rejects (never brick the scope)', async () => { + const r = new LeaseRegistry(); + r.acquire(CHAIN, WC1); + await assert.rejects(r.takeover(CHAIN, WC2, async () => { throw new Error('wedged'); })); + // old lease is gone regardless; scope recoverable + assert.equal(r.getHolder(CHAIN), null); + assert.equal(r.acquire(CHAIN, WC2).granted, true); + r.dispose(); +}); + +test('takeover of an unheld scope is a plain acquire', async () => { + const r = new LeaseRegistry(); + const result = await r.takeover(CHAIN, WC1); + assert.equal(result.granted, true); + r.dispose(); +}); + +test('refcounted demand: idempotent per holder, active while count > 0', () => { + const r = new LeaseRegistry(); + const changes = collect(r, 'demand-changed'); + + assert.equal(r.acquireDemand('capture', WC1), true); + assert.equal(r.acquireDemand('capture', WC1), true); // idempotent — no double count + assert.equal(r.demandCount('capture'), 1); + r.acquireDemand('capture', WC2); + assert.equal(r.demandCount('capture'), 2); + assert.equal(r.demandActive('capture'), true); + + r.releaseDemand('capture', WC1); + assert.equal(r.demandActive('capture'), true); + r.releaseDemand('capture', WC2); + assert.equal(r.demandActive('capture'), false); + // events carried count + active on every transition + assert.deepEqual(changes.map(c => c.count), [1, 2, 1, 0]); + r.dispose(); +}); + +test('user stop suspends demands, only resume restores them (§8.3)', () => { + const r = new LeaseRegistry(); + r.acquireDemand('capture', WC1); + r.acquireDemand('detection:desktop-main', WC2); + const suspended = collect(r, 'demand-suspended'); + const resumed = collect(r, 'demand-resumed'); + + r.suspendDemands('capture'); + assert.equal(r.demandActive('capture'), false); + assert.equal(r.demandCount('capture'), 1); // registration kept, not cleared + assert.equal(r.demandActive('detection:desktop-main'), true); // untouched + assert.equal(suspended.length, 1); + + // a new demand while suspended registers but does not activate the scope + r.acquireDemand('capture', WC2); + assert.equal(r.demandCount('capture'), 2); + assert.equal(r.demandActive('capture'), false); + + r.resumeDemands('capture'); + assert.equal(r.demandActive('capture'), true); + assert.equal(resumed.length, 1); + r.dispose(); +}); + +test('holder death releases everything immediately', () => { + const r = new LeaseRegistry(); + const released = collect(r, 'lease-released'); + r.acquire(CHAIN, WC1); + r.acquire('playback', WC1); + r.acquire('device-config', WC2); + r.acquireDemand('capture', WC1); + r.acquireDemand('detection:desktop-main', WC1); + + r.releaseHolder(WC1); + assert.equal(r.getHolder(CHAIN), null); + assert.equal(r.getHolder('playback'), null); + assert.equal(r.getHolder('device-config'), WC2); // other holders untouched + assert.equal(r.demandCount('capture'), 0); + assert.equal(r.demandCount('detection:desktop-main'), 0); + assert.deepEqual(released.map(e => e.reason), ['holder-destroyed', 'holder-destroyed']); + r.dispose(); +}); + +test('reload grace window: same identity restores under a new holder id (§8.2)', () => { + const r = new LeaseRegistry({ graceMs: 60_000 }); + r.acquire(CHAIN, WC1); + r.acquireDemand('capture', WC1); + + r.beginGrace(WC1, 'plugin:nam_tone'); + // scope is free-standing during grace: getHolder empty, but restorable + assert.equal(r.getHolder(CHAIN), null); + assert.equal(r.demandCount('capture'), 0); + + const restored = r.tryRestore('plugin:nam_tone', 'wc:9#nam_tone'); + assert.deepEqual(restored.sort(), [CHAIN, 'capture'].sort()); + assert.equal(r.getHolder(CHAIN), 'wc:9#nam_tone'); + assert.equal(r.demandCount('capture'), 1); + r.dispose(); +}); + +test('grace window: a waiter that grabbed the scope meanwhile wins', () => { + const r = new LeaseRegistry({ graceMs: 60_000 }); + r.acquire(CHAIN, WC1); + r.beginGrace(WC1, 'plugin:nam_tone'); + r.acquire(CHAIN, WC2); // waiter takes it during the gap + + const restored = r.tryRestore('plugin:nam_tone', 'wc:9#nam_tone'); + assert.deepEqual(restored, []); + assert.equal(r.getHolder(CHAIN), WC2); + r.dispose(); +}); + +test('grace window expiry releases for real', async () => { + const r = new LeaseRegistry({ graceMs: 20 }); + const released = collect(r, 'lease-released'); + r.acquire(CHAIN, WC1); + r.beginGrace(WC1, 'plugin:nam_tone'); + await new Promise(resolve => setTimeout(resolve, 60)); + assert.deepEqual(released.map(e => e.reason), ['grace-expired']); + assert.deepEqual(r.tryRestore('plugin:nam_tone', 'wc:9#nam_tone'), []); + assert.equal(r.acquire(CHAIN, WC2).granted, true); + r.dispose(); +}); + +test('destroy during grace cancels restore', () => { + const r = new LeaseRegistry({ graceMs: 60_000 }); + r.acquire(CHAIN, WC1); + r.beginGrace(WC1, 'plugin:nam_tone'); + r.releaseHolder(WC1); // renderer destroyed while grace pending + assert.deepEqual(r.tryRestore('plugin:nam_tone', 'wc:9#nam_tone'), []); + assert.equal(r.acquire(CHAIN, WC2).granted, true); + r.dispose(); +}); + +test('layered values: override rides the lease, base survives it (§2/§8.10)', () => { + const r = new LeaseRegistry(); + const changes = collect(r, 'value-changed'); + + r.setBase(CHAIN, 'noise-gate.threshold', -70); + assert.equal(r.getEffectiveValue(CHAIN, 'noise-gate.threshold'), -70); + + // override refused without the lease + assert.equal(r.setOverride(CHAIN, 'noise-gate.threshold', -55, WC1), false); + + r.acquire(CHAIN, WC1); + assert.equal(r.setOverride(CHAIN, 'noise-gate.threshold', -55, WC1), true); + assert.equal(r.getEffectiveValue(CHAIN, 'noise-gate.threshold'), -55); + assert.equal(r.hasOverride(CHAIN, 'noise-gate.threshold'), true); + + // base write during override persists but does not pierce (§8.10) + r.setBase(CHAIN, 'noise-gate.threshold', -80); + assert.equal(r.getEffectiveValue(CHAIN, 'noise-gate.threshold'), -55); + + // release restores the (updated) base + r.release(CHAIN, WC1); + assert.equal(r.getEffectiveValue(CHAIN, 'noise-gate.threshold'), -80); + assert.equal(r.hasOverride(CHAIN, 'noise-gate.threshold'), false); + const last = changes[changes.length - 1]; + assert.equal(last.value, -80); + assert.equal(last.overridden, false); + r.dispose(); +}); + +test('snapshot names holders with ages (diag visibility, §8.7)', () => { + let t = 1000; + const r = new LeaseRegistry({ now: () => t }); + r.acquire(CHAIN, WC1); + r.acquireDemand('detection:desktop-main', WC2); + t = 5000; + const snap = r.snapshot(); + assert.deepEqual(snap.leases, [{ scope: CHAIN, holderId: WC1, heldMs: 4000, revoking: false, overrides: [] }]); + assert.equal(snap.demands.length, 1); + assert.deepEqual(snap.demands[0].holders, [{ holderId: WC2, heldMs: 4000 }]); + r.dispose(); +});