diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6b51badbfc..596908ece4 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -92,7 +92,13 @@ single-instance lock is the only authority for the shared development profile. A second launch exits with an explicit conflict instead of scanning for or terminating an existing Electron process. Plain dev owns its direct child; the TCC launcher consumes only the one-shot lock verdict and never owns or -signals the detached app process. +signals the detached app process. When a launch loses the race, the conflict +message names the holder from Chromium's own `SingletonLock` record (hostname +and PID). The hostname is classified first: a record from another machine is +reported as that host without ever probing the local process table, while a +local record counts only while its PID is still alive. A stale local symlink, +a path-shaped or malformed record, or anything unresolvable falls back to the +generic wording. Known limitation: Chromium may kill an unresponsive lock holder after its 20-second acknowledgement timeout and let the new instance take the lock. diff --git a/apps/desktop/scripts/dev-app-runtime.mjs b/apps/desktop/scripts/dev-app-runtime.mjs index 9f6464f8f7..e55af8160b 100644 --- a/apps/desktop/scripts/dev-app-runtime.mjs +++ b/apps/desktop/scripts/dev-app-runtime.mjs @@ -51,11 +51,12 @@ import { existsSync, mkdirSync, readFileSync, + readlinkSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; -import { homedir, tmpdir } from 'node:os'; +import { homedir, hostname, tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -214,6 +215,67 @@ export async function readDevelopmentLaunchResult(resultFile, loader) { } } +/** + * The shared dev profile's userData dir on this platform: Electron derives it + * from the 'Maka Dev' app name. Windows has no POSIX SingletonLock symlink, so + * it resolves to null there and the conflict detail degrades to the generic + * wording (#3539). + */ +export function defaultDevUserDataDir(platform = process.platform) { + if (platform === 'darwin') return DEV_USER_DATA_DIR; + if (platform === 'linux') { + const xdgConfigHome = process.env.XDG_CONFIG_HOME || join(homedir(), '.config'); + return join(xdgConfigHome, 'Maka Dev'); + } + return null; +} + +/** PID liveness for the scripts side; EPERM counts as alive (other user). */ +function pidLiveness(pid) { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === 'EPERM'; + } +} + +/** + * Holder identity for a lost launch race, resolved from Chromium's own + * SingletonLock record (see #3539) — never from the process table. An explicit + * `--user-data-dir` wins over the platform default, matching launch behavior; + * on platforms without a POSIX lock symlink the detail is empty. The pure + * record classifier comes from @maka/core; the symlink read and the liveness + * probe are this script's own OS effects. Empty string when nothing + * trustworthy resolves, so callers fall back to the generic wording verbatim. + */ +export async function developmentProfileConflictDetail(options = {}) { + const explicitUserDataDir = splitDevelopmentCliArgs(options.argv ?? []).userDataDir; + const platform = options.platform ?? process.platform; + const userDataDir = + explicitUserDataDir ?? options.userDataDir ?? defaultDevUserDataDir(platform); + if (!userDataDir) return ''; + const loader = + options.loader ?? (() => import('@maka/core/dev-single-instance-owner')); + let mod; + try { + mod = await loader(); + } catch { + return ''; + } + if (typeof mod?.resolveLiveDevProfileOwnerFromTarget !== 'function') return ''; + try { + const target = readlinkSync(join(userDataDir, 'SingletonLock'), 'utf8'); + const owner = mod.resolveLiveDevProfileOwnerFromTarget(target, { + liveness: options.liveness ?? pidLiveness, + localHostname: options.localHostname ?? hostname(), + }); + return owner ? ` The holder appears to be ${mod.describeDevProfileOwner(owner)}.` : ''; + } catch { + return ''; + } +} + /** Exit-code check for the plain child (child is the app process). */ export async function plainLoserExitCode(code, loader) { const constants = await devSingleInstanceConstants(loader); @@ -391,11 +453,12 @@ export async function startDevelopmentApp(options = {}) { env: viteUrl ? { ...process.env, VITE_DEV_SERVER_URL: viteUrl } : process.env, }); child.once('exit', (code) => { - void plainLoserExitCode(code).then((loser) => { + void plainLoserExitCode(code).then(async (loser) => { if (!loser) return; + const holder = await developmentProfileConflictDetail({ argv }); console.error( 'Maka Dev could not start: another instance holds the shared profile lock ' + - `(loser exit code ${code}). Quit it (Cmd-Q) and retry.`, + `(loser exit code ${code}).${holder} Quit it (Cmd-Q) and retry.`, ); }); }); @@ -539,12 +602,15 @@ export async function waitForDevelopmentLaunchVerdict(options = {}) { */ export function handleDevelopmentLaunchOutcome(outcome, effects = {}) { const log = effects.log ?? console.error; + const conflictDetail = effects.conflictDetail ?? ''; const exit = effects.exit ?? ((code) => { process.exitCode = code; }); switch (outcome) { case 'started': return; case 'absorbed': - log('Maka Dev was absorbed by another instance holding the profile lock; quitting.'); + log( + `Maka Dev was absorbed by another instance holding the profile lock${conflictDetail}; quitting.`, + ); exit(1); return; case 'never-started': diff --git a/apps/desktop/scripts/dev-app-runtime.test.mjs b/apps/desktop/scripts/dev-app-runtime.test.mjs index 8ba66ec576..6055eda93c 100644 --- a/apps/desktop/scripts/dev-app-runtime.test.mjs +++ b/apps/desktop/scripts/dev-app-runtime.test.mjs @@ -18,6 +18,9 @@ */ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import { mkdtempSync, symlinkSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; import { test } from 'node:test'; test('a launcher signal cancels preparation before Electron can spawn', async () => { @@ -313,6 +316,130 @@ test('other launch outcomes retain their exit codes', async () => { assert.equal(startedExitCode, undefined); }); +test('an absorbed launch names the holder when the conflict detail resolves', async () => { + const { handleDevelopmentLaunchOutcome } = await import('./dev-app-runtime.mjs'); + const logs = []; + let exitCode; + handleDevelopmentLaunchOutcome('absorbed', { + log: (message) => logs.push(message), + exit: (code) => { exitCode = code; }, + conflictDetail: ' The holder appears to be PID 739 on this machine.', + }); + assert.equal(exitCode, 1); + assert.match(logs[0], /absorbed by another instance/); + assert.match(logs[0], /PID 739 on this machine/); +}); + +test('an absorbed launch without a resolvable holder keeps the generic wording', async () => { + const { handleDevelopmentLaunchOutcome } = await import('./dev-app-runtime.mjs'); + const logs = []; + handleDevelopmentLaunchOutcome('absorbed', { + log: (message) => logs.push(message), + exit: () => {}, + }); + assert.equal(logs[0], 'Maka Dev was absorbed by another instance holding the profile lock; quitting.'); +}); + +function makeLockDir(target) { + const dir = mkdtempSync(join(tmpdir(), 'maka-owner-')); + symlinkSync(target, join(dir, 'SingletonLock')); + return dir; +} + +test('conflict detail resolves the holder through the core owner module', async () => { + const { developmentProfileConflictDetail } = await import('./dev-app-runtime.mjs'); + const dir = makeLockDir('mac.local-739'); + const seen = []; + const detail = await developmentProfileConflictDetail({ + userDataDir: dir, + localHostname: 'mac.local', + loader: async () => ({ + resolveLiveDevProfileOwnerFromTarget: (target, deps) => { + seen.push([target, deps.localHostname]); + return deps.localHostname === 'mac.local' + ? { hostname: 'mac.local', pid: 739, isLocalHost: true } + : undefined; + }, + describeDevProfileOwner: (owner) => `PID ${owner.pid} on this machine`, + }), + }); + assert.deepEqual(seen, [['mac.local-739', 'mac.local']]); + assert.equal(detail, ' The holder appears to be PID 739 on this machine.'); +}); + +test('an explicit --user-data-dir wins over the platform default', async () => { + const { developmentProfileConflictDetail } = await import('./dev-app-runtime.mjs'); + const explicitDir = makeLockDir('explicit-box-5'); + const defaultDir = makeLockDir('default-box-9'); + const detail = await developmentProfileConflictDetail({ + argv: [`--user-data-dir=${explicitDir}`, '--other'], + userDataDir: defaultDir, + loader: async () => ({ + resolveLiveDevProfileOwnerFromTarget: (target, deps) => + target === 'explicit-box-5' ? { hostname: 'explicit-box', pid: 5, isLocalHost: false } : undefined, + describeDevProfileOwner: (owner) => `PID ${owner.pid}`, + }), + }); + assert.equal(detail, ' The holder appears to be PID 5.'); +}); + +test('the platform default is macOS and Linux capable, and Windows degrades', async () => { + const { defaultDevUserDataDir, developmentProfileConflictDetail } = await import( + './dev-app-runtime.mjs' + ); + assert.equal(defaultDevUserDataDir('win32'), null); + assert.equal(defaultDevUserDataDir('darwin'), join(homedir(), 'Library', 'Application Support', 'Maka Dev')); + const linuxConfigHome = process.env.XDG_CONFIG_HOME || join(homedir(), '.config'); + assert.equal(defaultDevUserDataDir('linux'), join(linuxConfigHome, 'Maka Dev')); + // Without an explicit dir on Windows there is no POSIX SingletonLock to + // read, so the detail degrades before any loader runs. + let loaded = false; + assert.equal( + await developmentProfileConflictDetail({ + platform: 'win32', + loader: async () => { + loaded = true; + return {}; + }, + }), + '', + ); + assert.equal(loaded, false); +}); + +test('conflict detail degrades to empty on any resolution failure', async () => { + const { developmentProfileConflictDetail } = await import('./dev-app-runtime.mjs'); + const dir = makeLockDir('mac.local-739'); + const failingLoader = async () => { + throw new Error('not built'); + }; + assert.equal( + await developmentProfileConflictDetail({ userDataDir: dir, loader: failingLoader }), + '', + ); + // A module without the expected surface degrades the same way. + assert.equal( + await developmentProfileConflictDetail({ userDataDir: dir, loader: async () => ({}) }), + '', + ); + // So does a resolver that throws. + assert.equal( + await developmentProfileConflictDetail({ + userDataDir: dir, + loader: async () => ({ + resolveLiveDevProfileOwnerFromTarget: () => { + throw new Error('boom'); + }, + describeDevProfileOwner: () => '', + }), + }), + '', + ); + // And a userData dir with no lock symlink at all. + const empty = mkdtempSync(join(tmpdir(), 'maka-owner-')); + assert.equal(await developmentProfileConflictDetail({ userDataDir: empty }), ''); +}); + test('shared-profile launches warn about legacy TCC data before choosing plain or bundle mode', async () => { const { DEV_USER_DATA_DIR, warnAboutLegacyTccDataRoot } = await import('./dev-app-runtime.mjs'); const warnings = []; diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs index f73bdec843..0312a7828e 100644 --- a/apps/desktop/scripts/dev.mjs +++ b/apps/desktop/scripts/dev.mjs @@ -44,6 +44,7 @@ import { build as esbuildBuild } from 'esbuild'; import { buildCursorOverlay } from '../../../scripts/build-cursor-overlay.mjs'; import { createDevelopmentLaunchSession, + developmentProfileConflictDetail, handleDevelopmentLaunchOutcome, waitForDevelopmentLaunchVerdict, } from './dev-app-runtime.mjs'; @@ -184,12 +185,18 @@ if (app) { }); // All branching lives in handleDevelopmentLaunchOutcome; this line is the only // un-automated surface here (launcher scripts are non-exported, darwin-only). - waitForDevelopmentLaunchVerdict({ stopped: launchSession.isStopping, resultFile: app.resultFile }).then((outcome) => - handleDevelopmentLaunchOutcome(outcome, { - log: (m) => console.error('[dev]', m), - exit: (code) => launchSession.stop(code), - }), - ); + waitForDevelopmentLaunchVerdict({ stopped: launchSession.isStopping, resultFile: app.resultFile }) + .then(async (outcome) => { + const conflictDetail = + outcome === 'absorbed' + ? await developmentProfileConflictDetail({ argv: process.argv.slice(2) }) + : ''; + handleDevelopmentLaunchOutcome(outcome, { + log: (m) => console.error('[dev]', m), + exit: (code) => launchSession.stop(code), + conflictDetail, + }); + }); } else { app.child.on('exit', (code) => launchSession.stop(code ?? 0)); } diff --git a/apps/desktop/scripts/start-dev-app.mjs b/apps/desktop/scripts/start-dev-app.mjs index e9a88b7ff2..ee86226e45 100644 --- a/apps/desktop/scripts/start-dev-app.mjs +++ b/apps/desktop/scripts/start-dev-app.mjs @@ -19,6 +19,7 @@ */ import { + developmentProfileConflictDetail, handleDevelopmentLaunchOutcome, startDevelopmentApp, waitForDevelopmentLaunchVerdict, @@ -58,9 +59,14 @@ if (app.isMacosBundle) { }); // All decisions live in handleDevelopmentLaunchOutcome (see dev.mjs for the // one-line coupling note); this is the only unobserved part. + const conflictDetail = + outcome === 'absorbed' + ? await developmentProfileConflictDetail({ argv: process.argv.slice(2) }) + : ''; handleDevelopmentLaunchOutcome(outcome, { log: (m) => console.error('[dev-app]', m), exit: (code) => stop(code), + conflictDetail, }); } else { app.child.on('exit', (code, signal) => { diff --git a/apps/desktop/src/main/dev-profile-owner.ts b/apps/desktop/src/main/dev-profile-owner.ts new file mode 100644 index 0000000000..4b65b987cd --- /dev/null +++ b/apps/desktop/src/main/dev-profile-owner.ts @@ -0,0 +1,61 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +// OS-effect half of the dev-profile holder lookup (#3539): reads Chromium's +// SingletonLock symlink and probes PID liveness, then classifies the record +// with the pure resolver in @maka/core. Desktop owns these effects — core +// stays pure contracts. + +import { + describeDevProfileOwner, + type DevProfileOwner, + resolveLiveDevProfileOwnerFromTarget, +} from '@maka/core/dev-single-instance-owner'; +import { readlinkSync } from 'node:fs'; +import { hostname } from 'node:os'; +import process from 'node:process'; + +function liveness(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + // EPERM: the process exists but belongs to another user — still a holder. + return (error as NodeJS.ErrnoException)?.code === 'EPERM'; + } +} + +/** + * Resolve the live holder of a dev profile from its SingletonLock record, or + * undefined when nothing trustworthy resolves (no readable symlink, malformed + * or stale record) so callers keep the generic conflict wording. + */ +export function resolveLiveDevProfileOwner(userDataDir: string): DevProfileOwner | undefined { + let target: string; + try { + target = readlinkSync(`${userDataDir}/SingletonLock`, 'utf8'); + } catch { + return undefined; + } + return resolveLiveDevProfileOwnerFromTarget(target, { + liveness, + localHostname: hostname(), + }); +} + +export { describeDevProfileOwner }; diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 6b0405cd58..524a4cae98 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -26,6 +26,10 @@ import { import { app, clipboard, dialog, ipcMain } from 'electron'; import { join } from 'node:path'; import { resolveBuildInfo } from './build-info.js'; +import { + describeDevProfileOwner, + resolveLiveDevProfileOwner, +} from './dev-profile-owner.js'; import { captureDesktopDiagnosticEnvironment, copyDesktopDiagnosticReport, @@ -83,9 +87,13 @@ if (!app.requestSingleInstanceLock()) { // is a semantic boundary. const resultReported = reportDevelopmentLaunchResult(process.argv, { status: 'loser' }); if (!resultReported && shouldShowLoserDialog(process.argv)) { + // Name the holder from Chromium's SingletonLock record when it resolves; + // any inconsistency degrades to the generic wording (see #3539). + const owner = resolveLiveDevProfileOwner(app.getPath('userData')); + const holder = owner ? ` The holder appears to be ${describeDevProfileOwner(owner)}.` : ''; dialog.showErrorBox( 'Maka Dev', - `Another instance holds the Maka Dev profile (${app.getPath('userData')}). Quit it and retry.`, + `Another instance holds the Maka Dev profile (${app.getPath('userData')}).${holder} Quit it and retry.`, ); } app.exit(DEV_LOSER_EXIT_CODE); diff --git a/packages/core/package.json b/packages/core/package.json index 88bb6d7655..08f2eded0e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -137,7 +137,8 @@ "./tool-result-status": "./dist/tool-result-status.js", "./ui-locale": "./dist/ui-locale.js", "./unified-diff": "./dist/unified-diff.js", - "./dev-single-instance": "./dist/dev-single-instance.js" + "./dev-single-instance": "./dist/dev-single-instance.js", + "./dev-single-instance-owner": "./dist/dev-single-instance-owner.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", diff --git a/packages/core/src/__tests__/dev-single-instance-owner.test.ts b/packages/core/src/__tests__/dev-single-instance-owner.test.ts new file mode 100644 index 0000000000..8ab80527a6 --- /dev/null +++ b/packages/core/src/__tests__/dev-single-instance-owner.test.ts @@ -0,0 +1,125 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + describeDevProfileOwner, + parseDevProfileLockTarget, + resolveLiveDevProfileOwnerFromTarget, +} from '../dev-single-instance-owner.js'; + +test('parse reads hostname and pid by splitting from the end', () => { + // Observed real-machine format: bare `-`. + assert.deepEqual(parseDevProfileLockTarget('macbookair.home-739'), { + hostname: 'macbookair.home', + pid: 739, + }); +}); + +test('parse keeps dashed hostnames intact because the pid is the final segment', () => { + assert.deepEqual(parseDevProfileLockTarget('my-mac-book.local-42'), { + hostname: 'my-mac-book.local', + pid: 42, + }); +}); + +test('parse rejects path-shaped targets instead of widening them into owners', () => { + // #3539 authorizes only the bare lock record; anything path-shaped is + // someone else's symlink layout and must degrade to the generic wording. + assert.equal( + parseDevProfileLockTarget('/Users/dev/Library/Application Support/Maka Dev-192.168.0.2-5'), + undefined, + ); + assert.equal(parseDevProfileLockTarget('/tmp/other-box-739'), undefined); + assert.equal(parseDevProfileLockTarget('C:\\Users\\dev\\AppData-host-739'), undefined); +}); + +test('parse rejects malformed targets instead of guessing', () => { + assert.equal(parseDevProfileLockTarget(''), undefined); + assert.equal(parseDevProfileLockTarget(' '), undefined); + assert.equal(parseDevProfileLockTarget('no-dash-at-all'), undefined); + assert.equal(parseDevProfileLockTarget('-123'), undefined); + assert.equal(parseDevProfileLockTarget('host-'), undefined); + assert.equal(parseDevProfileLockTarget('host-notapid'), undefined); + assert.equal(parseDevProfileLockTarget('host-12x'), undefined); +}); + +const alive = () => true; +const dead = () => false; + +test('resolve reports a live local holder from a lock target', () => { + const owner = resolveLiveDevProfileOwnerFromTarget('mac.local-739', { + liveness: alive, + localHostname: 'Mac.Local', + }); + assert.deepEqual(owner, { hostname: 'mac.local', pid: 739, isLocalHost: true }); + assert.equal(describeDevProfileOwner(owner!), 'PID 739 on this machine'); +}); + +test('resolve classifies a remote record without probing the local process table', () => { + let probes = 0; + const owner = resolveLiveDevProfileOwnerFromTarget('other-box-739', { + liveness: () => { + probes += 1; + return true; + }, + localHostname: 'mac.local', + }); + // Hostname is compared first: a remote hostname is never validated against + // local PIDs, whether or not some unrelated local process reuses the id. + assert.equal(probes, 0); + assert.deepEqual(owner, { hostname: 'other-box', pid: 739, isLocalHost: false }); + assert.equal(describeDevProfileOwner(owner!), 'PID 739 on host "other-box"'); +}); + +test('resolve returns undefined for a stale local record whose pid is gone', () => { + const owner = resolveLiveDevProfileOwnerFromTarget('mac.local-739', { + liveness: dead, + localHostname: 'mac.local', + }); + assert.equal(owner, undefined); +}); + +test('resolve degrades to undefined without a local hostname to compare against', () => { + // Without the local hostname the record cannot be classified: naming it as + // a remote holder could launder a stale local lock, so nothing is reported. + assert.equal(resolveLiveDevProfileOwnerFromTarget('mac.local-739'), undefined); + assert.equal( + resolveLiveDevProfileOwnerFromTarget('mac.local-739', { liveness: alive }), + undefined, + ); +}); + +test('resolve degrades to undefined on malformed targets', () => { + assert.equal( + resolveLiveDevProfileOwnerFromTarget('garbage', { localHostname: 'mac.local' }), + undefined, + ); + assert.equal(resolveLiveDevProfileOwnerFromTarget('', { localHostname: 'mac.local' }), undefined); +}); + +test('a probe that throws is treated as no trustworthy holder', () => { + const owner = resolveLiveDevProfileOwnerFromTarget('mac.local-739', { + liveness: () => { + throw new Error('boom'); + }, + localHostname: 'mac.local', + }); + assert.equal(owner, undefined); +}); diff --git a/packages/core/src/dev-single-instance-owner.ts b/packages/core/src/dev-single-instance-owner.ts new file mode 100644 index 0000000000..45476e4d56 --- /dev/null +++ b/packages/core/src/dev-single-instance-owner.ts @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +// Names the live holder of a shared dev profile from Chromium's own lock +// record, never from process-table reconstruction (#3539). Electron's +// requestSingleInstanceLock leaves a `SingletonLock` symlink in the user-data +// dir whose target encodes `-`. This module is PURE: it parses +// and classifies the record, while reading the symlink and probing PID +// liveness are OS effects injected by the Desktop callers that own them. + +/** One parsed `-` SingletonLock target. */ +export interface DevProfileOwnerRecord { + readonly hostname: string; + readonly pid: number; +} + +/** + * Parse a bare `-` SingletonLock target into its hostname and + * PID. Hostnames may themselves contain dashes, so the split runs from the + * end: the last segment is the PID, everything before it is the hostname. + * Only the bare record shape is accepted: anything path-shaped (a separator + * anywhere) or otherwise malformed returns undefined rather than being + * widened into a plausible owner. + */ +export function parseDevProfileLockTarget(target: string): DevProfileOwnerRecord | undefined { + const trimmed = target.trim(); + if (trimmed.length === 0) return undefined; + if (trimmed.includes('/') || trimmed.includes('\\')) return undefined; + const dash = trimmed.lastIndexOf('-'); + if (dash <= 0) return undefined; + const pidText = trimmed.slice(dash + 1); + if (!/^\d+$/.test(pidText)) return undefined; + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) return undefined; + const hostnamePart = trimmed.slice(0, dash); + if (hostnamePart.length === 0) return undefined; + return { hostname: hostnamePart, pid }; +} + +/** + * Whether the PID still exists, as reported by the Desktop caller. `EPERM` + * counts as alive there: an existing process owned by another user is a + * holder we may not signal, not an empty slot. + */ +export type DevProfileLivenessProbe = (pid: number) => boolean; + +/** A SingletonLock record classified against this machine. */ +export interface DevProfileOwner extends DevProfileOwnerRecord { + /** False when the lock was written by a different machine (shared homes). */ + readonly isLocalHost: boolean; +} + +export interface DevProfileOwnerResolutionDeps { + /** Required to classify the record; supplied by the Desktop caller. */ + localHostname?: string; + /** Probes PID liveness; required for local records to count as holders. */ + liveness?: DevProfileLivenessProbe; +} + +/** + * Classify a SingletonLock target. The hostname is compared FIRST, and the + * liveness probe runs only for records written by this machine: a remote + * hostname must never be validated against the local process table (#3539). + * Undefined when there is nothing trustworthy to report: a malformed record, + * an unknown local hostname, or a local record whose PID is no longer alive + * (stale debris from a holder that died without Cleanup()). + */ +export function resolveLiveDevProfileOwnerFromTarget( + target: string, + deps: DevProfileOwnerResolutionDeps = {}, +): DevProfileOwner | undefined { + const record = parseDevProfileLockTarget(target); + if (!record) return undefined; + const localHostname = deps.localHostname?.toLowerCase(); + if (!localHostname) return undefined; + const isLocalHost = record.hostname.toLowerCase() === localHostname; + if (isLocalHost) { + try { + if (!deps.liveness?.(record.pid)) return undefined; + } catch { + return undefined; + } + } + return { ...record, isLocalHost }; +} + +/** Human-readable holder identity for embedding in conflict messages. */ +export function describeDevProfileOwner(owner: DevProfileOwner): string { + return owner.isLocalHost + ? `PID ${owner.pid} on this machine` + : `PID ${owner.pid} on host "${owner.hostname}"`; +}