@agimon-ai/foundation-process-registry 0.17.3 → 0.17.5
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/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./ProcessRegistryService.cjs`);let t=require(`node:crypto`),n=require(`node:fs`);n=e.S(n,1);let r=require(`node:fs/promises`);r=e.S(r,1);let i=require(`node:path`);i=e.S(i,1);const a=[`exit`,`SIGINT`,`SIGTERM`,`uncaughtException`,`unhandledRejection`],o=new Map,s=new Map;function c(e){try{return process.kill(e,0),!0}catch{return!1}}function l(e){return new Promise(t=>setTimeout(t,e))}function u(e,t){try{let r=n.default.readFileSync(e,`utf-8`);JSON.parse(r).token===t&&n.default.unlinkSync(e)}catch{}}function d(){if(s.size>0)return;let e=()=>{for(let[e,t]of o)u(e,t);o.clear()};for(let t of a)process.once(t,e),s.set(t,e)}function f(){if(!(o.size>0)){for(let[e,t]of s)process.off(e,t);s.clear()}}async function p(e,t){try{let n=await r.default.readFile(e,`utf-8`),i=JSON.parse(n);if(i.pid&&c(i.pid)){let e=Date.now()-new Date(i.createdAt).getTime();return!(Number.isFinite(e)&&e<t)}return!0}catch{return!0}}async function m(e,
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});const e=require(`./ProcessRegistryService.cjs`);let t=require(`node:crypto`),n=require(`node:fs`);n=e.S(n,1);let r=require(`node:fs/promises`);r=e.S(r,1);let i=require(`node:path`);i=e.S(i,1);const a=[`exit`,`SIGINT`,`SIGTERM`,`uncaughtException`,`unhandledRejection`],o=new Map,s=new Map;function c(e){try{return process.kill(e,0),!0}catch{return!1}}function l(e){return new Promise(t=>setTimeout(t,e))}function u(e,t){try{let r=n.default.readFileSync(e,`utf-8`);JSON.parse(r).token===t&&n.default.unlinkSync(e)}catch{}}function d(){if(s.size>0)return;let e=()=>{for(let[e,t]of o)u(e,t);o.clear()};for(let t of a)process.once(t,e),s.set(t,e)}function f(){if(!(o.size>0)){for(let[e,t]of s)process.off(e,t);s.clear()}}async function p(e,t){try{let n=await r.default.readFile(e,`utf-8`),i=JSON.parse(n);if(i.pid&&c(i.pid)){let e=Date.now()-new Date(i.createdAt).getTime();return!(Number.isFinite(e)&&e<t)}return!0}catch{return!0}}async function m(e,n,a){await r.default.mkdir(i.default.dirname(e),{recursive:!0});for(let i=0;i<a.maxRetries;i+=1){let s={pid:process.pid,token:n,createdAt:new Date().toISOString()},c=`${e}.${process.pid}.${(0,t.randomBytes)(4).toString(`hex`)}.acquire`;try{await r.default.writeFile(c,JSON.stringify(s),`utf-8`),await r.default.link(c,e),o.set(e,n),d();return}catch(t){if(t.code!==`EEXIST`)throw Error(`Failed to acquire file lock '${e}': ${t instanceof Error?t.message:String(t)}`,{cause:t});if(await p(e,a.staleAfterMs)){await r.default.unlink(e).catch(()=>void 0),--i;continue}await l(a.retryDelayMs)}finally{await r.default.unlink(c).catch(()=>void 0)}}throw Error(`Unable to acquire file lock '${e}' (timeout)`)}async function h(e,n,i){let a=`${e}.${process.pid}.${(0,t.randomBytes)(4).toString(`hex`)}.tmp`;try{let t=await r.default.readFile(e,`utf-8`);if(JSON.parse(t).token!==n||i())return;let o={pid:process.pid,token:n,createdAt:new Date().toISOString()};if(await r.default.writeFile(a,JSON.stringify(o),`utf-8`),i())return;await r.default.rename(a,e)}catch{}finally{await r.default.unlink(a).catch(()=>void 0)}}async function g(e,t){o.delete(e);try{let n=await r.default.readFile(e,`utf-8`);JSON.parse(n).token===t&&await r.default.unlink(e)}catch{}finally{f()}}async function _(e,n,r={}){let i=r.retryDelayMs??75,a=r.maxRetries??80,o=r.staleAfterMs??5e3,s=r.heartbeatIntervalMs??Math.max(500,Math.floor(o/3)),c=`${process.pid}-${(0,t.randomBytes)(6).toString(`hex`)}`;await m(e,c,{retryDelayMs:i,maxRetries:a,staleAfterMs:o});let l=!1,u=null,d=setInterval(()=>{l||u||(u=h(e,c,()=>l).finally(()=>{u=null}))},s);d.unref?.();try{return await n()}finally{l=!0,clearInterval(d),u&&await u,await g(e,c)}}function v(t){return new e.t(t??process.env.PROCESS_REGISTRY_PATH)}async function y(t,n){let r=n??v(),i=t.pid??process.pid,a=t.serviceType??`tool`,o=t.environment??process.env.NODE_ENV??`development`,s={repositoryPath:t.repositoryPath,serviceName:t.serviceName,serviceType:a,environment:o,pid:i,port:t.port,host:t.host,command:t.command,args:t.args,tags:e.b(t.tags),metadata:t.metadata,force:t.force??!0},c=await r.registerProcess(s);if(!c.success||!c.record)throw Error(c.error||`Failed to register process for ${t.serviceName}`);let l=!1;return{release:async e=>{if(l)return;l=!0;let n=await r.releaseProcess({repositoryPath:t.repositoryPath,serviceName:t.serviceName,serviceType:a,pid:i,environment:o,kill:e?.kill??!0,releasePort:e?.releasePort??!0});if(!n.success&&n.error&&!n.error.includes(`No matching process entry`))throw Error(n.error||`Failed to release process for ${t.serviceName}`)}}}exports.DEFAULT_REGISTRY_LOCK_PATH=e.f,exports.DEFAULT_REGISTRY_PATH=e.p,exports.LOCK_MAX_RETRIES=e.m,exports.LOCK_RETRY_DELAY_MS=e.h,exports.LOCK_STALE_AFTER_MS=e.g,exports.ListProcessFiltersSchema=e.n,exports.PROCESS_REGISTRY_TAG_ENV_VAR=e._,exports.ProcessMetadataSchema=e.r,exports.ProcessRegistryError=e.i,exports.ProcessRegistryRecordSchema=e.a,exports.ProcessRegistryResponseSchema=e.o,exports.ProcessRegistryService=e.t,exports.ProcessRegistryStateSchema=e.s,exports.REGISTRY_VERSION=e.c,exports.RegisterProcessRequestSchema=e.l,exports.ReleaseProcessRequestSchema=e.u,exports.ServiceCategorySchema=e.d,exports.createProcessLease=y,exports.createProcessRegistryService=v,exports.makeTempPath=e.v,exports.normalizeRepositoryPath=e.y,exports.resolveProcessTags=e.b,exports.resolveSiblingRegistryPath=e.x,exports.withFileLock=_;
|
|
2
2
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.cjs","names":["fsSync","fs","path","ProcessRegistryService","resolveProcessTags"],"sources":["../src/services/FileLock.ts","../src/services/ProcessLease.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\nimport fsSync from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_AFTER_MS } from '../utils';\n\n/**\n * Generic cross-process advisory file lock.\n *\n * Reuses the same mechanism the registries use internally (an exclusive `wx`\n * lockfile with stale-lock detection), but is reusable around any critical\n * section — not just a single registry write. It adds a heartbeat so a\n * legitimately long hold (e.g. waiting for a child runtime to boot) is not\n * mistaken for a stale lock, and reference-counted process cleanup so a crash\n * always releases the lock without permanently altering crash semantics.\n */\n\ninterface FileLockState {\n pid: number;\n token: string;\n createdAt: string;\n}\n\nexport interface WithFileLockOptions {\n /** Delay between acquisition attempts (ms). Default {@link LOCK_RETRY_DELAY_MS}. */\n retryDelayMs?: number;\n /** Max acquisition attempts before throwing. Default {@link LOCK_MAX_RETRIES}. */\n maxRetries?: number;\n /** Age after which a lock held by a live PID is reclaimable (ms). Default {@link LOCK_STALE_AFTER_MS}. */\n staleAfterMs?: number;\n /** Interval at which a held lock refreshes its timestamp (ms). Default `staleAfterMs / 3`. */\n heartbeatIntervalMs?: number;\n}\n\nconst CLEANUP_EVENTS = ['exit', 'SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection'] as const;\ntype CleanupEvent = (typeof CLEANUP_EVENTS)[number];\n\n/** Locks currently held by this process, keyed by lock path → token. Released synchronously on process exit. */\nconst heldLocks = new Map<string, string>();\nconst cleanupHandlers = new Map<CleanupEvent, () => void>();\n\nfunction isProcessRunning(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction releaseLockSync(lockPath: string, token: string): void {\n try {\n const existing = fsSync.readFileSync(lockPath, 'utf-8');\n const parsed = JSON.parse(existing) as { token?: string };\n if (parsed.token === token) {\n fsSync.unlinkSync(lockPath);\n }\n } catch {\n // best-effort cleanup\n }\n}\n\nfunction registerCleanupIfNeeded(): void {\n if (cleanupHandlers.size > 0) {\n return;\n }\n\n const cleanup = (): void => {\n for (const [lockPath, token] of heldLocks) {\n releaseLockSync(lockPath, token);\n }\n heldLocks.clear();\n };\n\n for (const event of CLEANUP_EVENTS) {\n process.once(event, cleanup);\n cleanupHandlers.set(event, cleanup);\n }\n}\n\nfunction unregisterCleanupIfIdle(): void {\n if (heldLocks.size > 0) {\n return;\n }\n\n for (const [event, handler] of cleanupHandlers) {\n process.off(event, handler);\n }\n cleanupHandlers.clear();\n}\n\nasync function isStaleLock(lockPath: string, staleAfterMs: number): Promise<boolean> {\n try {\n const content = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(content) as FileLockState;\n\n if (parsed.pid && isProcessRunning(parsed.pid)) {\n const age = Date.now() - new Date(parsed.createdAt).getTime();\n return !(Number.isFinite(age) && age < staleAfterMs);\n }\n\n return true;\n } catch {\n return true;\n }\n}\n\nasync function acquireLock(\n lockPath: string,\n token: string,\n options: Required<Omit<WithFileLockOptions, 'heartbeatIntervalMs'>>,\n): Promise<void> {\n await fs.mkdir(path.dirname(lockPath), { recursive: true });\n\n for (let attempt = 0; attempt < options.maxRetries; attempt += 1) {\n try {\n const state: FileLockState = { pid: process.pid, token, createdAt: new Date().toISOString() };\n await fs.writeFile(lockPath, JSON.stringify(state), { flag: 'wx' });\n heldLocks.set(lockPath, token);\n registerCleanupIfNeeded();\n return;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {\n throw new Error(\n `Failed to acquire file lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`,\n { cause: error },\n );\n }\n\n if (await isStaleLock(lockPath, options.staleAfterMs)) {\n await fs.unlink(lockPath).catch(() => undefined);\n attempt -= 1;\n continue;\n }\n\n await delay(options.retryDelayMs);\n }\n }\n\n throw new Error(`Unable to acquire file lock '${lockPath}' (timeout)`);\n}\n\nasync function refreshLock(lockPath: string, token: string): Promise<void> {\n try {\n const content = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(content) as FileLockState;\n if (parsed.token !== token) {\n return;\n }\n\n const state: FileLockState = { pid: process.pid, token, createdAt: new Date().toISOString() };\n const tempPath = `${lockPath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;\n await fs.writeFile(tempPath, JSON.stringify(state), 'utf-8');\n await fs.rename(tempPath, lockPath);\n } catch {\n // ignore — if the lockfile vanished, release handles the rest\n }\n}\n\nasync function releaseLock(lockPath: string, token: string): Promise<void> {\n heldLocks.delete(lockPath);\n try {\n const existing = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(existing) as { token?: string };\n if (parsed.token === token) {\n await fs.unlink(lockPath);\n }\n } catch {\n // ignore\n } finally {\n unregisterCleanupIfIdle();\n }\n}\n\n/**\n * Run `fn` while holding an exclusive cross-process lock at `lockPath`.\n * The lock is always released when `fn` settles (or the process exits).\n */\nexport async function withFileLock<T>(\n lockPath: string,\n fn: () => Promise<T>,\n options: WithFileLockOptions = {},\n): Promise<T> {\n const retryDelayMs = options.retryDelayMs ?? LOCK_RETRY_DELAY_MS;\n const maxRetries = options.maxRetries ?? LOCK_MAX_RETRIES;\n const staleAfterMs = options.staleAfterMs ?? LOCK_STALE_AFTER_MS;\n const heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.max(500, Math.floor(staleAfterMs / 3));\n const token = `${process.pid}-${randomBytes(6).toString('hex')}`;\n\n await acquireLock(lockPath, token, { retryDelayMs, maxRetries, staleAfterMs });\n\n const heartbeat = setInterval(() => {\n void refreshLock(lockPath, token);\n }, heartbeatIntervalMs);\n // Never keep the event loop alive solely for the heartbeat.\n heartbeat.unref?.();\n\n try {\n return await fn();\n } finally {\n clearInterval(heartbeat);\n await releaseLock(lockPath, token);\n }\n}\n","import type { RegisterProcessRequest, ServiceCategory } from '../types';\nimport { resolveProcessTags } from '../utils';\nimport { ProcessRegistryService } from './ProcessRegistryService';\n\nexport interface ProcessLease {\n release(options?: { kill?: boolean; releasePort?: boolean }): Promise<void>;\n}\n\nexport interface ProcessLeaseOptions {\n repositoryPath: string;\n serviceName: string;\n serviceType?: ServiceCategory;\n environment?: string;\n pid?: number;\n port?: number;\n host?: string;\n command?: string;\n args?: string[];\n tags?: string[];\n metadata?: Record<string, unknown>;\n force?: boolean;\n}\n\nexport function createProcessRegistryService(registryPath?: string): ProcessRegistryService {\n return new ProcessRegistryService(registryPath ?? process.env.PROCESS_REGISTRY_PATH);\n}\n\nexport async function createProcessLease(\n options: ProcessLeaseOptions,\n service?: ProcessRegistryService,\n): Promise<ProcessLease> {\n const registry = service ?? createProcessRegistryService();\n const pid = options.pid ?? process.pid;\n const serviceType = options.serviceType ?? 'tool';\n const environment = options.environment ?? process.env.NODE_ENV ?? 'development';\n\n const request: RegisterProcessRequest = {\n repositoryPath: options.repositoryPath,\n serviceName: options.serviceName,\n serviceType,\n environment,\n pid,\n port: options.port,\n host: options.host,\n command: options.command,\n args: options.args,\n tags: resolveProcessTags(options.tags),\n metadata: options.metadata,\n force: options.force ?? true,\n };\n\n const result = await registry.registerProcess(request);\n\n if (!result.success || !result.record) {\n throw new Error(result.error || `Failed to register process for ${options.serviceName}`);\n }\n\n let released = false;\n return {\n release: async (releaseOptions?: { kill?: boolean; releasePort?: boolean }): Promise<void> => {\n if (released) {\n return;\n }\n\n released = true;\n const releaseResult = await registry.releaseProcess({\n repositoryPath: options.repositoryPath,\n serviceName: options.serviceName,\n serviceType,\n pid,\n environment,\n kill: releaseOptions?.kill ?? true,\n releasePort: releaseOptions?.releasePort ?? true,\n });\n\n if (!releaseResult.success && releaseResult.error && !releaseResult.error.includes('No matching process entry')) {\n throw new Error(releaseResult.error || `Failed to release process for ${options.serviceName}`);\n }\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.cjs","names":["fsSync","fs","path","ProcessRegistryService","resolveProcessTags"],"sources":["../src/services/FileLock.ts","../src/services/ProcessLease.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\nimport fsSync from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_AFTER_MS } from '../utils';\n\n/**\n * Generic cross-process advisory file lock.\n *\n * Reuses the same mechanism the registries use internally (an exclusive `wx`\n * lockfile with stale-lock detection), but is reusable around any critical\n * section — not just a single registry write. It adds a heartbeat so a\n * legitimately long hold (e.g. waiting for a child runtime to boot) is not\n * mistaken for a stale lock, and reference-counted process cleanup so a crash\n * always releases the lock without permanently altering crash semantics.\n */\n\ninterface FileLockState {\n pid: number;\n token: string;\n createdAt: string;\n}\n\nexport interface WithFileLockOptions {\n /** Delay between acquisition attempts (ms). Default {@link LOCK_RETRY_DELAY_MS}. */\n retryDelayMs?: number;\n /** Max acquisition attempts before throwing. Default {@link LOCK_MAX_RETRIES}. */\n maxRetries?: number;\n /** Age after which a lock held by a live PID is reclaimable (ms). Default {@link LOCK_STALE_AFTER_MS}. */\n staleAfterMs?: number;\n /** Interval at which a held lock refreshes its timestamp (ms). Default `staleAfterMs / 3`. */\n heartbeatIntervalMs?: number;\n}\n\nconst CLEANUP_EVENTS = ['exit', 'SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection'] as const;\ntype CleanupEvent = (typeof CLEANUP_EVENTS)[number];\n\n/** Locks currently held by this process, keyed by lock path → token. Released synchronously on process exit. */\nconst heldLocks = new Map<string, string>();\nconst cleanupHandlers = new Map<CleanupEvent, () => void>();\n\nfunction isProcessRunning(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction releaseLockSync(lockPath: string, token: string): void {\n try {\n const existing = fsSync.readFileSync(lockPath, 'utf-8');\n const parsed = JSON.parse(existing) as { token?: string };\n if (parsed.token === token) {\n fsSync.unlinkSync(lockPath);\n }\n } catch {\n // best-effort cleanup\n }\n}\n\nfunction registerCleanupIfNeeded(): void {\n if (cleanupHandlers.size > 0) {\n return;\n }\n\n const cleanup = (): void => {\n for (const [lockPath, token] of heldLocks) {\n releaseLockSync(lockPath, token);\n }\n heldLocks.clear();\n };\n\n for (const event of CLEANUP_EVENTS) {\n process.once(event, cleanup);\n cleanupHandlers.set(event, cleanup);\n }\n}\n\nfunction unregisterCleanupIfIdle(): void {\n if (heldLocks.size > 0) {\n return;\n }\n\n for (const [event, handler] of cleanupHandlers) {\n process.off(event, handler);\n }\n cleanupHandlers.clear();\n}\n\nasync function isStaleLock(lockPath: string, staleAfterMs: number): Promise<boolean> {\n try {\n const content = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(content) as FileLockState;\n\n if (parsed.pid && isProcessRunning(parsed.pid)) {\n const age = Date.now() - new Date(parsed.createdAt).getTime();\n return !(Number.isFinite(age) && age < staleAfterMs);\n }\n\n return true;\n } catch {\n return true;\n }\n}\n\nasync function acquireLock(\n lockPath: string,\n token: string,\n options: Required<Omit<WithFileLockOptions, 'heartbeatIntervalMs'>>,\n): Promise<void> {\n await fs.mkdir(path.dirname(lockPath), { recursive: true });\n\n for (let attempt = 0; attempt < options.maxRetries; attempt += 1) {\n const state: FileLockState = { pid: process.pid, token, createdAt: new Date().toISOString() };\n const tempPath = `${lockPath}.${process.pid}.${randomBytes(4).toString('hex')}.acquire`;\n\n try {\n // Publish the lock payload atomically: write it in full to a private temp\n // file, then hard-link that into place. `link` is an atomic exclusive\n // create (EEXIST when the lock is held), so the lock file is never visible\n // half-written. A bare `wx` write briefly exposes a zero-byte file between\n // create and write, which a concurrent acquirer reads as a corrupt lock\n // and wrongly reclaims — breaking mutual exclusion.\n await fs.writeFile(tempPath, JSON.stringify(state), 'utf-8');\n await fs.link(tempPath, lockPath);\n heldLocks.set(lockPath, token);\n registerCleanupIfNeeded();\n return;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {\n throw new Error(\n `Failed to acquire file lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`,\n { cause: error },\n );\n }\n\n if (await isStaleLock(lockPath, options.staleAfterMs)) {\n await fs.unlink(lockPath).catch(() => undefined);\n attempt -= 1;\n continue;\n }\n\n await delay(options.retryDelayMs);\n } finally {\n await fs.unlink(tempPath).catch(() => undefined);\n }\n }\n\n throw new Error(`Unable to acquire file lock '${lockPath}' (timeout)`);\n}\n\nasync function refreshLock(lockPath: string, token: string, shouldAbort: () => boolean): Promise<void> {\n const tempPath = `${lockPath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;\n try {\n const content = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(content) as FileLockState;\n if (parsed.token !== token || shouldAbort()) {\n return;\n }\n\n const state: FileLockState = { pid: process.pid, token, createdAt: new Date().toISOString() };\n await fs.writeFile(tempPath, JSON.stringify(state), 'utf-8');\n // Re-check after the async write: once release has begun, renaming would\n // resurrect a lock file that releaseLock already removed, leaking it on disk.\n if (shouldAbort()) {\n return;\n }\n await fs.rename(tempPath, lockPath);\n } catch {\n // ignore — if the lockfile vanished, release handles the rest\n } finally {\n // The rename consumes tempPath on success; clean it up on every other path\n // so an interrupted refresh never leaves a stray file in the lock directory.\n await fs.unlink(tempPath).catch(() => undefined);\n }\n}\n\nasync function releaseLock(lockPath: string, token: string): Promise<void> {\n heldLocks.delete(lockPath);\n try {\n const existing = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(existing) as { token?: string };\n if (parsed.token === token) {\n await fs.unlink(lockPath);\n }\n } catch {\n // ignore\n } finally {\n unregisterCleanupIfIdle();\n }\n}\n\n/**\n * Run `fn` while holding an exclusive cross-process lock at `lockPath`.\n * The lock is always released when `fn` settles (or the process exits).\n */\nexport async function withFileLock<T>(\n lockPath: string,\n fn: () => Promise<T>,\n options: WithFileLockOptions = {},\n): Promise<T> {\n const retryDelayMs = options.retryDelayMs ?? LOCK_RETRY_DELAY_MS;\n const maxRetries = options.maxRetries ?? LOCK_MAX_RETRIES;\n const staleAfterMs = options.staleAfterMs ?? LOCK_STALE_AFTER_MS;\n const heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.max(500, Math.floor(staleAfterMs / 3));\n const token = `${process.pid}-${randomBytes(6).toString('hex')}`;\n\n await acquireLock(lockPath, token, { retryDelayMs, maxRetries, staleAfterMs });\n\n let releasing = false;\n let inFlightRefresh: Promise<void> | null = null;\n\n const heartbeat = setInterval(() => {\n // Single-flight: skip ticks while a refresh is still running or once we have\n // started releasing, so at most one refresh is ever outstanding.\n if (releasing || inFlightRefresh) {\n return;\n }\n inFlightRefresh = refreshLock(lockPath, token, () => releasing).finally(() => {\n inFlightRefresh = null;\n });\n }, heartbeatIntervalMs);\n // Never keep the event loop alive solely for the heartbeat.\n heartbeat.unref?.();\n\n try {\n return await fn();\n } finally {\n releasing = true;\n clearInterval(heartbeat);\n // Let any in-flight refresh settle before deleting the lock, otherwise its\n // pending rename could recreate the lock file right after releaseLock removes it.\n if (inFlightRefresh) {\n await inFlightRefresh;\n }\n await releaseLock(lockPath, token);\n }\n}\n","import type { RegisterProcessRequest, ServiceCategory } from '../types';\nimport { resolveProcessTags } from '../utils';\nimport { ProcessRegistryService } from './ProcessRegistryService';\n\nexport interface ProcessLease {\n release(options?: { kill?: boolean; releasePort?: boolean }): Promise<void>;\n}\n\nexport interface ProcessLeaseOptions {\n repositoryPath: string;\n serviceName: string;\n serviceType?: ServiceCategory;\n environment?: string;\n pid?: number;\n port?: number;\n host?: string;\n command?: string;\n args?: string[];\n tags?: string[];\n metadata?: Record<string, unknown>;\n force?: boolean;\n}\n\nexport function createProcessRegistryService(registryPath?: string): ProcessRegistryService {\n return new ProcessRegistryService(registryPath ?? process.env.PROCESS_REGISTRY_PATH);\n}\n\nexport async function createProcessLease(\n options: ProcessLeaseOptions,\n service?: ProcessRegistryService,\n): Promise<ProcessLease> {\n const registry = service ?? createProcessRegistryService();\n const pid = options.pid ?? process.pid;\n const serviceType = options.serviceType ?? 'tool';\n const environment = options.environment ?? process.env.NODE_ENV ?? 'development';\n\n const request: RegisterProcessRequest = {\n repositoryPath: options.repositoryPath,\n serviceName: options.serviceName,\n serviceType,\n environment,\n pid,\n port: options.port,\n host: options.host,\n command: options.command,\n args: options.args,\n tags: resolveProcessTags(options.tags),\n metadata: options.metadata,\n force: options.force ?? true,\n };\n\n const result = await registry.registerProcess(request);\n\n if (!result.success || !result.record) {\n throw new Error(result.error || `Failed to register process for ${options.serviceName}`);\n }\n\n let released = false;\n return {\n release: async (releaseOptions?: { kill?: boolean; releasePort?: boolean }): Promise<void> => {\n if (released) {\n return;\n }\n\n released = true;\n const releaseResult = await registry.releaseProcess({\n repositoryPath: options.repositoryPath,\n serviceName: options.serviceName,\n serviceType,\n pid,\n environment,\n kill: releaseOptions?.kill ?? true,\n releasePort: releaseOptions?.releasePort ?? true,\n });\n\n if (!releaseResult.success && releaseResult.error && !releaseResult.error.includes('No matching process entry')) {\n throw new Error(releaseResult.error || `Failed to release process for ${options.serviceName}`);\n }\n },\n };\n}\n"],"mappings":"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"}
|
package/dist/index.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{_ as e,a as t,b as n,c as r,d as i,f as a,g as o,h as s,i as c,l,m as u,n as d,o as f,p,r as m,s as h,t as g,u as _,v,x as y,y as b}from"./ProcessRegistryService.mjs";import{randomBytes as x}from"node:crypto";import S from"node:fs";import C from"node:fs/promises";import w from"node:path";const T=[`exit`,`SIGINT`,`SIGTERM`,`uncaughtException`,`unhandledRejection`],E=new Map,D=new Map;function O(e){try{return process.kill(e,0),!0}catch{return!1}}function k(e){return new Promise(t=>setTimeout(t,e))}function A(e,t){try{let n=S.readFileSync(e,`utf-8`);JSON.parse(n).token===t&&S.unlinkSync(e)}catch{}}function j(){if(D.size>0)return;let e=()=>{for(let[e,t]of E)A(e,t);E.clear()};for(let t of T)process.once(t,e),D.set(t,e)}function M(){if(!(E.size>0)){for(let[e,t]of D)process.off(e,t);D.clear()}}async function N(e,t){try{let n=await C.readFile(e,`utf-8`),r=JSON.parse(n);if(r.pid&&O(r.pid)){let e=Date.now()-new Date(r.createdAt).getTime();return!(Number.isFinite(e)&&e<t)}return!0}catch{return!0}}async function P(e,t,n){await C.mkdir(w.dirname(e),{recursive:!0});for(let r=0;r<n.maxRetries;r+=1)
|
|
1
|
+
import{_ as e,a as t,b as n,c as r,d as i,f as a,g as o,h as s,i as c,l,m as u,n as d,o as f,p,r as m,s as h,t as g,u as _,v,x as y,y as b}from"./ProcessRegistryService.mjs";import{randomBytes as x}from"node:crypto";import S from"node:fs";import C from"node:fs/promises";import w from"node:path";const T=[`exit`,`SIGINT`,`SIGTERM`,`uncaughtException`,`unhandledRejection`],E=new Map,D=new Map;function O(e){try{return process.kill(e,0),!0}catch{return!1}}function k(e){return new Promise(t=>setTimeout(t,e))}function A(e,t){try{let n=S.readFileSync(e,`utf-8`);JSON.parse(n).token===t&&S.unlinkSync(e)}catch{}}function j(){if(D.size>0)return;let e=()=>{for(let[e,t]of E)A(e,t);E.clear()};for(let t of T)process.once(t,e),D.set(t,e)}function M(){if(!(E.size>0)){for(let[e,t]of D)process.off(e,t);D.clear()}}async function N(e,t){try{let n=await C.readFile(e,`utf-8`),r=JSON.parse(n);if(r.pid&&O(r.pid)){let e=Date.now()-new Date(r.createdAt).getTime();return!(Number.isFinite(e)&&e<t)}return!0}catch{return!0}}async function P(e,t,n){await C.mkdir(w.dirname(e),{recursive:!0});for(let r=0;r<n.maxRetries;r+=1){let i={pid:process.pid,token:t,createdAt:new Date().toISOString()},a=`${e}.${process.pid}.${x(4).toString(`hex`)}.acquire`;try{await C.writeFile(a,JSON.stringify(i),`utf-8`),await C.link(a,e),E.set(e,t),j();return}catch(t){if(t.code!==`EEXIST`)throw Error(`Failed to acquire file lock '${e}': ${t instanceof Error?t.message:String(t)}`,{cause:t});if(await N(e,n.staleAfterMs)){await C.unlink(e).catch(()=>void 0),--r;continue}await k(n.retryDelayMs)}finally{await C.unlink(a).catch(()=>void 0)}}throw Error(`Unable to acquire file lock '${e}' (timeout)`)}async function F(e,t,n){let r=`${e}.${process.pid}.${x(4).toString(`hex`)}.tmp`;try{let i=await C.readFile(e,`utf-8`);if(JSON.parse(i).token!==t||n())return;let a={pid:process.pid,token:t,createdAt:new Date().toISOString()};if(await C.writeFile(r,JSON.stringify(a),`utf-8`),n())return;await C.rename(r,e)}catch{}finally{await C.unlink(r).catch(()=>void 0)}}async function I(e,t){E.delete(e);try{let n=await C.readFile(e,`utf-8`);JSON.parse(n).token===t&&await C.unlink(e)}catch{}finally{M()}}async function L(e,t,n={}){let r=n.retryDelayMs??75,i=n.maxRetries??80,a=n.staleAfterMs??5e3,o=n.heartbeatIntervalMs??Math.max(500,Math.floor(a/3)),s=`${process.pid}-${x(6).toString(`hex`)}`;await P(e,s,{retryDelayMs:r,maxRetries:i,staleAfterMs:a});let c=!1,l=null,u=setInterval(()=>{c||l||(l=F(e,s,()=>c).finally(()=>{l=null}))},o);u.unref?.();try{return await t()}finally{c=!0,clearInterval(u),l&&await l,await I(e,s)}}function R(e){return new g(e??process.env.PROCESS_REGISTRY_PATH)}async function z(e,t){let r=t??R(),i=e.pid??process.pid,a=e.serviceType??`tool`,o=e.environment??process.env.NODE_ENV??`development`,s={repositoryPath:e.repositoryPath,serviceName:e.serviceName,serviceType:a,environment:o,pid:i,port:e.port,host:e.host,command:e.command,args:e.args,tags:n(e.tags),metadata:e.metadata,force:e.force??!0},c=await r.registerProcess(s);if(!c.success||!c.record)throw Error(c.error||`Failed to register process for ${e.serviceName}`);let l=!1;return{release:async t=>{if(l)return;l=!0;let n=await r.releaseProcess({repositoryPath:e.repositoryPath,serviceName:e.serviceName,serviceType:a,pid:i,environment:o,kill:t?.kill??!0,releasePort:t?.releasePort??!0});if(!n.success&&n.error&&!n.error.includes(`No matching process entry`))throw Error(n.error||`Failed to release process for ${e.serviceName}`)}}}export{a as DEFAULT_REGISTRY_LOCK_PATH,p as DEFAULT_REGISTRY_PATH,u as LOCK_MAX_RETRIES,s as LOCK_RETRY_DELAY_MS,o as LOCK_STALE_AFTER_MS,d as ListProcessFiltersSchema,e as PROCESS_REGISTRY_TAG_ENV_VAR,m as ProcessMetadataSchema,c as ProcessRegistryError,t as ProcessRegistryRecordSchema,f as ProcessRegistryResponseSchema,g as ProcessRegistryService,h as ProcessRegistryStateSchema,r as REGISTRY_VERSION,l as RegisterProcessRequestSchema,_ as ReleaseProcessRequestSchema,i as ServiceCategorySchema,z as createProcessLease,R as createProcessRegistryService,v as makeTempPath,b as normalizeRepositoryPath,n as resolveProcessTags,y as resolveSiblingRegistryPath,L as withFileLock};
|
|
2
2
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/services/FileLock.ts","../src/services/ProcessLease.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\nimport fsSync from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_AFTER_MS } from '../utils';\n\n/**\n * Generic cross-process advisory file lock.\n *\n * Reuses the same mechanism the registries use internally (an exclusive `wx`\n * lockfile with stale-lock detection), but is reusable around any critical\n * section — not just a single registry write. It adds a heartbeat so a\n * legitimately long hold (e.g. waiting for a child runtime to boot) is not\n * mistaken for a stale lock, and reference-counted process cleanup so a crash\n * always releases the lock without permanently altering crash semantics.\n */\n\ninterface FileLockState {\n pid: number;\n token: string;\n createdAt: string;\n}\n\nexport interface WithFileLockOptions {\n /** Delay between acquisition attempts (ms). Default {@link LOCK_RETRY_DELAY_MS}. */\n retryDelayMs?: number;\n /** Max acquisition attempts before throwing. Default {@link LOCK_MAX_RETRIES}. */\n maxRetries?: number;\n /** Age after which a lock held by a live PID is reclaimable (ms). Default {@link LOCK_STALE_AFTER_MS}. */\n staleAfterMs?: number;\n /** Interval at which a held lock refreshes its timestamp (ms). Default `staleAfterMs / 3`. */\n heartbeatIntervalMs?: number;\n}\n\nconst CLEANUP_EVENTS = ['exit', 'SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection'] as const;\ntype CleanupEvent = (typeof CLEANUP_EVENTS)[number];\n\n/** Locks currently held by this process, keyed by lock path → token. Released synchronously on process exit. */\nconst heldLocks = new Map<string, string>();\nconst cleanupHandlers = new Map<CleanupEvent, () => void>();\n\nfunction isProcessRunning(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction releaseLockSync(lockPath: string, token: string): void {\n try {\n const existing = fsSync.readFileSync(lockPath, 'utf-8');\n const parsed = JSON.parse(existing) as { token?: string };\n if (parsed.token === token) {\n fsSync.unlinkSync(lockPath);\n }\n } catch {\n // best-effort cleanup\n }\n}\n\nfunction registerCleanupIfNeeded(): void {\n if (cleanupHandlers.size > 0) {\n return;\n }\n\n const cleanup = (): void => {\n for (const [lockPath, token] of heldLocks) {\n releaseLockSync(lockPath, token);\n }\n heldLocks.clear();\n };\n\n for (const event of CLEANUP_EVENTS) {\n process.once(event, cleanup);\n cleanupHandlers.set(event, cleanup);\n }\n}\n\nfunction unregisterCleanupIfIdle(): void {\n if (heldLocks.size > 0) {\n return;\n }\n\n for (const [event, handler] of cleanupHandlers) {\n process.off(event, handler);\n }\n cleanupHandlers.clear();\n}\n\nasync function isStaleLock(lockPath: string, staleAfterMs: number): Promise<boolean> {\n try {\n const content = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(content) as FileLockState;\n\n if (parsed.pid && isProcessRunning(parsed.pid)) {\n const age = Date.now() - new Date(parsed.createdAt).getTime();\n return !(Number.isFinite(age) && age < staleAfterMs);\n }\n\n return true;\n } catch {\n return true;\n }\n}\n\nasync function acquireLock(\n lockPath: string,\n token: string,\n options: Required<Omit<WithFileLockOptions, 'heartbeatIntervalMs'>>,\n): Promise<void> {\n await fs.mkdir(path.dirname(lockPath), { recursive: true });\n\n for (let attempt = 0; attempt < options.maxRetries; attempt += 1) {\n try {\n const state: FileLockState = { pid: process.pid, token, createdAt: new Date().toISOString() };\n await fs.writeFile(lockPath, JSON.stringify(state), { flag: 'wx' });\n heldLocks.set(lockPath, token);\n registerCleanupIfNeeded();\n return;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {\n throw new Error(\n `Failed to acquire file lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`,\n { cause: error },\n );\n }\n\n if (await isStaleLock(lockPath, options.staleAfterMs)) {\n await fs.unlink(lockPath).catch(() => undefined);\n attempt -= 1;\n continue;\n }\n\n await delay(options.retryDelayMs);\n }\n }\n\n throw new Error(`Unable to acquire file lock '${lockPath}' (timeout)`);\n}\n\nasync function refreshLock(lockPath: string, token: string): Promise<void> {\n try {\n const content = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(content) as FileLockState;\n if (parsed.token !== token) {\n return;\n }\n\n const state: FileLockState = { pid: process.pid, token, createdAt: new Date().toISOString() };\n const tempPath = `${lockPath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;\n await fs.writeFile(tempPath, JSON.stringify(state), 'utf-8');\n await fs.rename(tempPath, lockPath);\n } catch {\n // ignore — if the lockfile vanished, release handles the rest\n }\n}\n\nasync function releaseLock(lockPath: string, token: string): Promise<void> {\n heldLocks.delete(lockPath);\n try {\n const existing = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(existing) as { token?: string };\n if (parsed.token === token) {\n await fs.unlink(lockPath);\n }\n } catch {\n // ignore\n } finally {\n unregisterCleanupIfIdle();\n }\n}\n\n/**\n * Run `fn` while holding an exclusive cross-process lock at `lockPath`.\n * The lock is always released when `fn` settles (or the process exits).\n */\nexport async function withFileLock<T>(\n lockPath: string,\n fn: () => Promise<T>,\n options: WithFileLockOptions = {},\n): Promise<T> {\n const retryDelayMs = options.retryDelayMs ?? LOCK_RETRY_DELAY_MS;\n const maxRetries = options.maxRetries ?? LOCK_MAX_RETRIES;\n const staleAfterMs = options.staleAfterMs ?? LOCK_STALE_AFTER_MS;\n const heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.max(500, Math.floor(staleAfterMs / 3));\n const token = `${process.pid}-${randomBytes(6).toString('hex')}`;\n\n await acquireLock(lockPath, token, { retryDelayMs, maxRetries, staleAfterMs });\n\n const heartbeat = setInterval(() => {\n void refreshLock(lockPath, token);\n }, heartbeatIntervalMs);\n // Never keep the event loop alive solely for the heartbeat.\n heartbeat.unref?.();\n\n try {\n return await fn();\n } finally {\n clearInterval(heartbeat);\n await releaseLock(lockPath, token);\n }\n}\n","import type { RegisterProcessRequest, ServiceCategory } from '../types';\nimport { resolveProcessTags } from '../utils';\nimport { ProcessRegistryService } from './ProcessRegistryService';\n\nexport interface ProcessLease {\n release(options?: { kill?: boolean; releasePort?: boolean }): Promise<void>;\n}\n\nexport interface ProcessLeaseOptions {\n repositoryPath: string;\n serviceName: string;\n serviceType?: ServiceCategory;\n environment?: string;\n pid?: number;\n port?: number;\n host?: string;\n command?: string;\n args?: string[];\n tags?: string[];\n metadata?: Record<string, unknown>;\n force?: boolean;\n}\n\nexport function createProcessRegistryService(registryPath?: string): ProcessRegistryService {\n return new ProcessRegistryService(registryPath ?? process.env.PROCESS_REGISTRY_PATH);\n}\n\nexport async function createProcessLease(\n options: ProcessLeaseOptions,\n service?: ProcessRegistryService,\n): Promise<ProcessLease> {\n const registry = service ?? createProcessRegistryService();\n const pid = options.pid ?? process.pid;\n const serviceType = options.serviceType ?? 'tool';\n const environment = options.environment ?? process.env.NODE_ENV ?? 'development';\n\n const request: RegisterProcessRequest = {\n repositoryPath: options.repositoryPath,\n serviceName: options.serviceName,\n serviceType,\n environment,\n pid,\n port: options.port,\n host: options.host,\n command: options.command,\n args: options.args,\n tags: resolveProcessTags(options.tags),\n metadata: options.metadata,\n force: options.force ?? true,\n };\n\n const result = await registry.registerProcess(request);\n\n if (!result.success || !result.record) {\n throw new Error(result.error || `Failed to register process for ${options.serviceName}`);\n }\n\n let released = false;\n return {\n release: async (releaseOptions?: { kill?: boolean; releasePort?: boolean }): Promise<void> => {\n if (released) {\n return;\n }\n\n released = true;\n const releaseResult = await registry.releaseProcess({\n repositoryPath: options.repositoryPath,\n serviceName: options.serviceName,\n serviceType,\n pid,\n environment,\n kill: releaseOptions?.kill ?? true,\n releasePort: releaseOptions?.releasePort ?? true,\n });\n\n if (!releaseResult.success && releaseResult.error && !releaseResult.error.includes('No matching process entry')) {\n throw new Error(releaseResult.error || `Failed to release process for ${options.serviceName}`);\n }\n },\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/services/FileLock.ts","../src/services/ProcessLease.ts"],"sourcesContent":["import { randomBytes } from 'node:crypto';\nimport fsSync from 'node:fs';\nimport fs from 'node:fs/promises';\nimport path from 'node:path';\nimport { LOCK_MAX_RETRIES, LOCK_RETRY_DELAY_MS, LOCK_STALE_AFTER_MS } from '../utils';\n\n/**\n * Generic cross-process advisory file lock.\n *\n * Reuses the same mechanism the registries use internally (an exclusive `wx`\n * lockfile with stale-lock detection), but is reusable around any critical\n * section — not just a single registry write. It adds a heartbeat so a\n * legitimately long hold (e.g. waiting for a child runtime to boot) is not\n * mistaken for a stale lock, and reference-counted process cleanup so a crash\n * always releases the lock without permanently altering crash semantics.\n */\n\ninterface FileLockState {\n pid: number;\n token: string;\n createdAt: string;\n}\n\nexport interface WithFileLockOptions {\n /** Delay between acquisition attempts (ms). Default {@link LOCK_RETRY_DELAY_MS}. */\n retryDelayMs?: number;\n /** Max acquisition attempts before throwing. Default {@link LOCK_MAX_RETRIES}. */\n maxRetries?: number;\n /** Age after which a lock held by a live PID is reclaimable (ms). Default {@link LOCK_STALE_AFTER_MS}. */\n staleAfterMs?: number;\n /** Interval at which a held lock refreshes its timestamp (ms). Default `staleAfterMs / 3`. */\n heartbeatIntervalMs?: number;\n}\n\nconst CLEANUP_EVENTS = ['exit', 'SIGINT', 'SIGTERM', 'uncaughtException', 'unhandledRejection'] as const;\ntype CleanupEvent = (typeof CLEANUP_EVENTS)[number];\n\n/** Locks currently held by this process, keyed by lock path → token. Released synchronously on process exit. */\nconst heldLocks = new Map<string, string>();\nconst cleanupHandlers = new Map<CleanupEvent, () => void>();\n\nfunction isProcessRunning(pid: number): boolean {\n try {\n process.kill(pid, 0);\n return true;\n } catch {\n return false;\n }\n}\n\nfunction delay(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nfunction releaseLockSync(lockPath: string, token: string): void {\n try {\n const existing = fsSync.readFileSync(lockPath, 'utf-8');\n const parsed = JSON.parse(existing) as { token?: string };\n if (parsed.token === token) {\n fsSync.unlinkSync(lockPath);\n }\n } catch {\n // best-effort cleanup\n }\n}\n\nfunction registerCleanupIfNeeded(): void {\n if (cleanupHandlers.size > 0) {\n return;\n }\n\n const cleanup = (): void => {\n for (const [lockPath, token] of heldLocks) {\n releaseLockSync(lockPath, token);\n }\n heldLocks.clear();\n };\n\n for (const event of CLEANUP_EVENTS) {\n process.once(event, cleanup);\n cleanupHandlers.set(event, cleanup);\n }\n}\n\nfunction unregisterCleanupIfIdle(): void {\n if (heldLocks.size > 0) {\n return;\n }\n\n for (const [event, handler] of cleanupHandlers) {\n process.off(event, handler);\n }\n cleanupHandlers.clear();\n}\n\nasync function isStaleLock(lockPath: string, staleAfterMs: number): Promise<boolean> {\n try {\n const content = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(content) as FileLockState;\n\n if (parsed.pid && isProcessRunning(parsed.pid)) {\n const age = Date.now() - new Date(parsed.createdAt).getTime();\n return !(Number.isFinite(age) && age < staleAfterMs);\n }\n\n return true;\n } catch {\n return true;\n }\n}\n\nasync function acquireLock(\n lockPath: string,\n token: string,\n options: Required<Omit<WithFileLockOptions, 'heartbeatIntervalMs'>>,\n): Promise<void> {\n await fs.mkdir(path.dirname(lockPath), { recursive: true });\n\n for (let attempt = 0; attempt < options.maxRetries; attempt += 1) {\n const state: FileLockState = { pid: process.pid, token, createdAt: new Date().toISOString() };\n const tempPath = `${lockPath}.${process.pid}.${randomBytes(4).toString('hex')}.acquire`;\n\n try {\n // Publish the lock payload atomically: write it in full to a private temp\n // file, then hard-link that into place. `link` is an atomic exclusive\n // create (EEXIST when the lock is held), so the lock file is never visible\n // half-written. A bare `wx` write briefly exposes a zero-byte file between\n // create and write, which a concurrent acquirer reads as a corrupt lock\n // and wrongly reclaims — breaking mutual exclusion.\n await fs.writeFile(tempPath, JSON.stringify(state), 'utf-8');\n await fs.link(tempPath, lockPath);\n heldLocks.set(lockPath, token);\n registerCleanupIfNeeded();\n return;\n } catch (error) {\n if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {\n throw new Error(\n `Failed to acquire file lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`,\n { cause: error },\n );\n }\n\n if (await isStaleLock(lockPath, options.staleAfterMs)) {\n await fs.unlink(lockPath).catch(() => undefined);\n attempt -= 1;\n continue;\n }\n\n await delay(options.retryDelayMs);\n } finally {\n await fs.unlink(tempPath).catch(() => undefined);\n }\n }\n\n throw new Error(`Unable to acquire file lock '${lockPath}' (timeout)`);\n}\n\nasync function refreshLock(lockPath: string, token: string, shouldAbort: () => boolean): Promise<void> {\n const tempPath = `${lockPath}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;\n try {\n const content = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(content) as FileLockState;\n if (parsed.token !== token || shouldAbort()) {\n return;\n }\n\n const state: FileLockState = { pid: process.pid, token, createdAt: new Date().toISOString() };\n await fs.writeFile(tempPath, JSON.stringify(state), 'utf-8');\n // Re-check after the async write: once release has begun, renaming would\n // resurrect a lock file that releaseLock already removed, leaking it on disk.\n if (shouldAbort()) {\n return;\n }\n await fs.rename(tempPath, lockPath);\n } catch {\n // ignore — if the lockfile vanished, release handles the rest\n } finally {\n // The rename consumes tempPath on success; clean it up on every other path\n // so an interrupted refresh never leaves a stray file in the lock directory.\n await fs.unlink(tempPath).catch(() => undefined);\n }\n}\n\nasync function releaseLock(lockPath: string, token: string): Promise<void> {\n heldLocks.delete(lockPath);\n try {\n const existing = await fs.readFile(lockPath, 'utf-8');\n const parsed = JSON.parse(existing) as { token?: string };\n if (parsed.token === token) {\n await fs.unlink(lockPath);\n }\n } catch {\n // ignore\n } finally {\n unregisterCleanupIfIdle();\n }\n}\n\n/**\n * Run `fn` while holding an exclusive cross-process lock at `lockPath`.\n * The lock is always released when `fn` settles (or the process exits).\n */\nexport async function withFileLock<T>(\n lockPath: string,\n fn: () => Promise<T>,\n options: WithFileLockOptions = {},\n): Promise<T> {\n const retryDelayMs = options.retryDelayMs ?? LOCK_RETRY_DELAY_MS;\n const maxRetries = options.maxRetries ?? LOCK_MAX_RETRIES;\n const staleAfterMs = options.staleAfterMs ?? LOCK_STALE_AFTER_MS;\n const heartbeatIntervalMs = options.heartbeatIntervalMs ?? Math.max(500, Math.floor(staleAfterMs / 3));\n const token = `${process.pid}-${randomBytes(6).toString('hex')}`;\n\n await acquireLock(lockPath, token, { retryDelayMs, maxRetries, staleAfterMs });\n\n let releasing = false;\n let inFlightRefresh: Promise<void> | null = null;\n\n const heartbeat = setInterval(() => {\n // Single-flight: skip ticks while a refresh is still running or once we have\n // started releasing, so at most one refresh is ever outstanding.\n if (releasing || inFlightRefresh) {\n return;\n }\n inFlightRefresh = refreshLock(lockPath, token, () => releasing).finally(() => {\n inFlightRefresh = null;\n });\n }, heartbeatIntervalMs);\n // Never keep the event loop alive solely for the heartbeat.\n heartbeat.unref?.();\n\n try {\n return await fn();\n } finally {\n releasing = true;\n clearInterval(heartbeat);\n // Let any in-flight refresh settle before deleting the lock, otherwise its\n // pending rename could recreate the lock file right after releaseLock removes it.\n if (inFlightRefresh) {\n await inFlightRefresh;\n }\n await releaseLock(lockPath, token);\n }\n}\n","import type { RegisterProcessRequest, ServiceCategory } from '../types';\nimport { resolveProcessTags } from '../utils';\nimport { ProcessRegistryService } from './ProcessRegistryService';\n\nexport interface ProcessLease {\n release(options?: { kill?: boolean; releasePort?: boolean }): Promise<void>;\n}\n\nexport interface ProcessLeaseOptions {\n repositoryPath: string;\n serviceName: string;\n serviceType?: ServiceCategory;\n environment?: string;\n pid?: number;\n port?: number;\n host?: string;\n command?: string;\n args?: string[];\n tags?: string[];\n metadata?: Record<string, unknown>;\n force?: boolean;\n}\n\nexport function createProcessRegistryService(registryPath?: string): ProcessRegistryService {\n return new ProcessRegistryService(registryPath ?? process.env.PROCESS_REGISTRY_PATH);\n}\n\nexport async function createProcessLease(\n options: ProcessLeaseOptions,\n service?: ProcessRegistryService,\n): Promise<ProcessLease> {\n const registry = service ?? createProcessRegistryService();\n const pid = options.pid ?? process.pid;\n const serviceType = options.serviceType ?? 'tool';\n const environment = options.environment ?? process.env.NODE_ENV ?? 'development';\n\n const request: RegisterProcessRequest = {\n repositoryPath: options.repositoryPath,\n serviceName: options.serviceName,\n serviceType,\n environment,\n pid,\n port: options.port,\n host: options.host,\n command: options.command,\n args: options.args,\n tags: resolveProcessTags(options.tags),\n metadata: options.metadata,\n force: options.force ?? true,\n };\n\n const result = await registry.registerProcess(request);\n\n if (!result.success || !result.record) {\n throw new Error(result.error || `Failed to register process for ${options.serviceName}`);\n }\n\n let released = false;\n return {\n release: async (releaseOptions?: { kill?: boolean; releasePort?: boolean }): Promise<void> => {\n if (released) {\n return;\n }\n\n released = true;\n const releaseResult = await registry.releaseProcess({\n repositoryPath: options.repositoryPath,\n serviceName: options.serviceName,\n serviceType,\n pid,\n environment,\n kill: releaseOptions?.kill ?? true,\n releasePort: releaseOptions?.releasePort ?? true,\n });\n\n if (!releaseResult.success && releaseResult.error && !releaseResult.error.includes('No matching process entry')) {\n throw new Error(releaseResult.error || `Failed to release process for ${options.serviceName}`);\n }\n },\n };\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agimon-ai/foundation-process-registry",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.5",
|
|
4
4
|
"description": "Long-running process registry and cleanup coordination across worktrees",
|
|
5
5
|
"bin": {
|
|
6
6
|
"process-registry": "./dist/cli.cjs"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"zod": "4.4.1",
|
|
17
|
-
"@agimon-ai/foundation-port-registry": "0.17.
|
|
17
|
+
"@agimon-ai/foundation-port-registry": "0.17.5"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "25.6.0",
|