()
| 1046 | * (unlike mtime-based locking which requires a 30-day timeout) |
| 1047 | */ |
| 1048 | export async function lockCurrentVersion(): Promise<void> { |
| 1049 | const dirs = getBaseDirectories() |
| 1050 | |
| 1051 | // Only lock if we're running from the versions directory |
| 1052 | if (!process.execPath.includes(dirs.versions)) { |
| 1053 | return |
| 1054 | } |
| 1055 | |
| 1056 | const versionPath = resolve(process.execPath) |
| 1057 | try { |
| 1058 | const lockfilePath = getLockFilePathFromVersionPath(dirs, versionPath) |
| 1059 | |
| 1060 | // Ensure locks directory exists |
| 1061 | await mkdir(dirs.locks, { recursive: true }) |
| 1062 | |
| 1063 | if (isPidBasedLockingEnabled()) { |
| 1064 | // Acquire PID-based lock and hold it for the process lifetime |
| 1065 | // PID-based locking allows immediate detection of crashed processes |
| 1066 | // while still surviving laptop sleep (process is suspended but PID exists) |
| 1067 | const acquired = await acquireProcessLifetimeLock( |
| 1068 | versionPath, |
| 1069 | lockfilePath, |
| 1070 | ) |
| 1071 | |
| 1072 | if (!acquired) { |
| 1073 | logEvent('tengu_version_lock_failed', { |
| 1074 | is_pid_based: true, |
| 1075 | is_lifetime_lock: true, |
| 1076 | }) |
| 1077 | logLockAcquisitionError( |
| 1078 | versionPath, |
| 1079 | new Error('Lock already held by another process'), |
| 1080 | ) |
| 1081 | return |
| 1082 | } |
| 1083 | |
| 1084 | logEvent('tengu_version_lock_acquired', { |
| 1085 | is_pid_based: true, |
| 1086 | is_lifetime_lock: true, |
| 1087 | }) |
| 1088 | logForDebugging(`Acquired PID lock on running version: ${versionPath}`) |
| 1089 | } else { |
| 1090 | // Acquire mtime-based lock and never release it (until process exits) |
| 1091 | // Use 30 days for stale to prevent the lock from being considered stale during |
| 1092 | // normal usage. This is critical because laptop sleep suspends the process, |
| 1093 | // stopping the mtime heartbeat. 30 days is long enough for any realistic session |
| 1094 | // while still allowing eventual cleanup of abandoned locks. |
| 1095 | let release: (() => Promise<void>) | undefined |
| 1096 | try { |
| 1097 | release = await lockfile.lock(versionPath, { |
| 1098 | stale: LOCK_STALE_MS, |
| 1099 | retries: 0, // Don't retry - if we can't lock, that's fine |
| 1100 | lockfilePath, |
| 1101 | // Handle lock compromise gracefully (e.g., if another process deletes the lock directory) |
| 1102 | onCompromised: (err: Error) => { |
| 1103 | logForDebugging( |
| 1104 | `NON-FATAL: Lock on running version was compromised: ${err.message}`, |
| 1105 | { level: 'info' }, |
no test coverage detected