From 1d2be9e12cc1ed71e44f9cf91767614951997d43 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Wed, 26 Aug 2026 19:13:14 +0200 Subject: [PATCH 1/2] feat(desktop): name the dev-profile lock holder in conflict messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the observability half of the shared 'Maka Dev' profile launch race (#3539). When a launch loses the single-instance lock, the loser surfaces named the conflict only generically. Resolve the holder from Chromium's own SingletonLock record (a - symlink target in the user-data dir), never from the process table: the symlink alone is not evidence — it survives SIGKILL — so a holder is reported only while its PID is still alive (kill(pid, 0), EPERM counts as alive), and a hostname mismatch is reported as another machine for shared homes. - packages/core/dev-single-instance-owner: parse + liveness + describe, undefined on any inconsistency so every caller degrades to the existing generic wording. - All three loser surfaces name the holder: the main-process loser dialog, the absorbed outcome in dev.mjs / start-dev-app.mjs, and the plain-loser stderr in dev-app-runtime.mjs. An explicit --user-data-dir wins over the shared default, matching launch behavior. The message is hedged ("appears to be"), which also covers the inherent PID-reuse race. The race's atomic-reservation half stays open. Generated-by: Claude Code --- apps/desktop/README.md | 5 +- apps/desktop/scripts/dev-app-runtime.mjs | 37 ++++- apps/desktop/scripts/dev-app-runtime.test.mjs | 77 +++++++++++ apps/desktop/scripts/dev.mjs | 19 ++- apps/desktop/scripts/start-dev-app.mjs | 6 + apps/desktop/src/main/main.ts | 10 +- packages/core/package.json | 3 +- .../dev-single-instance-owner.test.ts | 119 ++++++++++++++++ .../core/src/dev-single-instance-owner.ts | 129 ++++++++++++++++++ 9 files changed, 393 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/__tests__/dev-single-instance-owner.test.ts create mode 100644 packages/core/src/dev-single-instance-owner.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6b51badbfc..ed684f6e03 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -92,7 +92,10 @@ 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) while that PID is still alive; a stale symlink, another machine's +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..2ab27606f0 100644 --- a/apps/desktop/scripts/dev-app-runtime.mjs +++ b/apps/desktop/scripts/dev-app-runtime.mjs @@ -214,6 +214,33 @@ export async function readDevelopmentLaunchResult(resultFile, loader) { } } +/** + * 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 shared default, matching launch behavior. + * 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 userDataDir = explicitUserDataDir ?? options.userDataDir ?? DEV_USER_DATA_DIR; + const loader = + options.loader ?? (() => import('@maka/core/dev-single-instance-owner')); + let mod; + try { + mod = await loader(); + } catch { + return ''; + } + if (typeof mod?.resolveLiveDevProfileOwner !== 'function') return ''; + try { + const owner = mod.resolveLiveDevProfileOwner(userDataDir); + 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 +418,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 +567,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..f7e279feb2 100644 --- a/apps/desktop/scripts/dev-app-runtime.test.mjs +++ b/apps/desktop/scripts/dev-app-runtime.test.mjs @@ -313,6 +313,83 @@ 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.'); +}); + +test('conflict detail resolves the holder through the core owner module', async () => { + const { developmentProfileConflictDetail } = await import('./dev-app-runtime.mjs'); + const detail = await developmentProfileConflictDetail({ + userDataDir: '/profile', + loader: async () => ({ + resolveLiveDevProfileOwner: (dir) => + dir === '/profile' ? { hostname: 'mac.local', pid: 739, isLocalHost: true } : undefined, + describeDevProfileOwner: (owner) => `PID ${owner.pid} on this machine`, + }), + }); + assert.equal(detail, ' The holder appears to be PID 739 on this machine.'); +}); + +test('an explicit --user-data-dir wins over the shared default profile', async () => { + const { developmentProfileConflictDetail } = await import('./dev-app-runtime.mjs'); + const seen = []; + const detail = await developmentProfileConflictDetail({ + argv: ['--user-data-dir=/explicit/dir', '--other'], + loader: async () => ({ + resolveLiveDevProfileOwner: (dir) => { + seen.push(dir); + return { hostname: 'mac.local', pid: 739, isLocalHost: true }; + }, + describeDevProfileOwner: (owner) => `PID ${owner.pid}`, + }), + }); + assert.deepEqual(seen, ['/explicit/dir']); + assert.equal(detail, ' The holder appears to be PID 739.'); +}); + +test('conflict detail degrades to empty on any resolution failure', async () => { + const { developmentProfileConflictDetail } = await import('./dev-app-runtime.mjs'); + const failingLoader = async () => { + throw new Error('not built'); + }; + assert.equal(await developmentProfileConflictDetail({ loader: failingLoader }), ''); + // A module without the expected surface degrades the same way. + assert.equal(await developmentProfileConflictDetail({ loader: async () => ({}) }), ''); + // So does a resolver that throws. + assert.equal( + await developmentProfileConflictDetail({ + userDataDir: '/profile', + loader: async () => ({ + resolveLiveDevProfileOwner: () => { + throw new Error('boom'); + }, + describeDevProfileOwner: () => '', + }), + }), + '', + ); +}); + 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/main.ts b/apps/desktop/src/main/main.ts index 6b0405cd58..0283f25753 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -23,6 +23,10 @@ import { developmentLaunchResultFile, shouldShowLoserDialog, } from '@maka/core/dev-single-instance'; +import { + describeDevProfileOwner, + resolveLiveDevProfileOwner, +} from '@maka/core/dev-single-instance-owner'; import { app, clipboard, dialog, ipcMain } from 'electron'; import { join } from 'node:path'; import { resolveBuildInfo } from './build-info.js'; @@ -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..c445692717 --- /dev/null +++ b/packages/core/src/__tests__/dev-single-instance-owner.test.ts @@ -0,0 +1,119 @@ +/* + * 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, + resolveLiveDevProfileOwner, +} 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 takes the final path segment of path-shaped targets', () => { + assert.deepEqual( + parseDevProfileLockTarget('/Users/dev/Library/Application Support/Maka Dev-192.168.0.2-5'), + { hostname: 'Maka Dev-192.168.0.2', pid: 5 }, + ); +}); + +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 an injected lock target', () => { + const owner = resolveLiveDevProfileOwner('/profile', { + readLockTarget: () => '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 flags foreign-host records without treating them as local pids', () => { + const owner = resolveLiveDevProfileOwner('/profile', { + readLockTarget: () => 'other-box-739', + liveness: alive, + localHostname: 'mac.local', + }); + 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 symlink whose pid is gone', () => { + const owner = resolveLiveDevProfileOwner('/profile', { + readLockTarget: () => 'mac.local-739', + liveness: dead, + localHostname: 'mac.local', + }); + assert.equal(owner, undefined); +}); + +test('resolve degrades to undefined on unreadable, missing, or malformed locks', () => { + assert.equal( + resolveLiveDevProfileOwner('/profile', { readLockTarget: () => undefined }), + undefined, + ); + assert.equal( + resolveLiveDevProfileOwner('/profile', { + readLockTarget: () => { + throw new Error('ENOENT'); + }, + }), + undefined, + ); + assert.equal( + resolveLiveDevProfileOwner('/profile', { readLockTarget: () => 'garbage' }), + undefined, + ); +}); + +test('a probe that throws is treated as no trustworthy holder', () => { + const owner = resolveLiveDevProfileOwner('/profile', { + readLockTarget: () => '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..793940b460 --- /dev/null +++ b/packages/core/src/dev-single-instance-owner.ts @@ -0,0 +1,129 @@ +/* + * 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 `-`. The symlink alone is not +// evidence — it survives SIGKILL — so a holder is reported only while its PID +// is still alive, and any inconsistency degrades to "unknown" so every caller +// can fall back to the generic conflict wording. + +import { readlinkSync } from 'node:fs'; +import { hostname } from 'node:os'; +import process from 'node:process'; + +/** One parsed `-` SingletonLock target. */ +export interface DevProfileOwnerRecord { + readonly hostname: string; + readonly pid: number; +} + +/** + * Parse the SingletonLock symlink 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. Returns undefined + * for anything malformed rather than guessing. + */ +export function parseDevProfileLockTarget(target: string): DevProfileOwnerRecord | undefined { + const trimmed = target.trim(); + if (trimmed.length === 0) return undefined; + // Defensive: older layouts are documented with a path-shaped target; take + // the final path segment either way. + const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')); + const name = separator >= 0 ? trimmed.slice(separator + 1) : trimmed; + const dash = name.lastIndexOf('-'); + if (dash <= 0) return undefined; + const pidText = name.slice(dash + 1); + if (!/^\d+$/.test(pidText)) return undefined; + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) return undefined; + const hostnamePart = name.slice(0, dash); + if (hostnamePart.length === 0) return undefined; + return { hostname: hostnamePart, pid }; +} + +/** + * Whether the PID still exists. `EPERM` counts as alive: an existing process + * owned by another user is a holder we may not signal, not an empty slot. + */ +export type DevProfileLivenessProbe = (pid: number) => boolean; + +function defaultLivenessProbe(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException)?.code === 'EPERM'; + } +} + +/** A SingletonLock whose recorded PID is still alive. */ +export interface DevProfileOwner extends DevProfileOwnerRecord { + /** False when the lock was written by a different machine (shared homes). */ + readonly isLocalHost: boolean; +} + +export interface DevProfileOwnerResolutionDeps { + /** Defaults to reading `/SingletonLock`. */ + readLockTarget?(): string | undefined; + liveness?: DevProfileLivenessProbe; + /** Defaults to this machine's hostname. */ + localHostname?: string; +} + +/** + * Resolve the profile holder from the lock's own record, or undefined when + * there is nothing trustworthy to report: no readable symlink, a malformed + * target, or a PID that no longer exists. A dead PID means the symlink is + * stale debris (the holder died without Cleanup()) — reportable as no holder. + */ +export function resolveLiveDevProfileOwner( + userDataDir: string, + deps: DevProfileOwnerResolutionDeps = {}, +): DevProfileOwner | undefined { + let target: string | undefined; + try { + target = deps.readLockTarget?.() ?? readTarget(userDataDir); + } catch { + return undefined; + } + if (target === undefined) return undefined; + const record = parseDevProfileLockTarget(target); + if (!record) return undefined; + const liveness = deps.liveness ?? defaultLivenessProbe; + try { + if (!liveness(record.pid)) return undefined; + } catch { + return undefined; + } + const local = (deps.localHostname ?? hostname()).toLowerCase(); + return { ...record, isLocalHost: record.hostname.toLowerCase() === local }; +} + +/** 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}"`; +} + +function readTarget(userDataDir: string): string | undefined { + const target = readlinkSync(`${userDataDir}/SingletonLock`, 'utf8'); + return target.length > 0 ? target : undefined; +} From 6af4d8cb82a3664d0843e6c7c6e0c4b327fe50a1 Mon Sep 17 00:00:00 2001 From: rbalachandar Date: Thu, 27 Aug 2026 19:33:53 +0200 Subject: [PATCH 2/2] =?UTF-8?q?fix(desktop):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20resolution=20order,=20strict=20parsing,=20platform?= =?UTF-8?q?=20default,=20core=20purity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #3953. - Classify the hostname FIRST and probe liveness only for records written by this machine: a remote hostname was previously validated against the local process table, dropping valid shared-home records or blessing an unrelated local PID as a live remote holder. A missing local hostname now degrades instead of guessing. - Parse only the bare - lock record. Path-shaped targets are rejected outright instead of being widened into plausible owners; the test that blessed that widening is inverted. - The scripts' default profile dir is platform-aware (macOS application support, Linux XDG config) and null on Windows, where no POSIX SingletonLock exists — the plain-loser detail degrades before any module load instead of inspecting a macOS path on other platforms. - Move the OS effects out of packages/core: the core module now only parses and classifies records (zero node imports), while the symlink read and PID liveness probe live in Desktop — a main-process resolver module and the launcher script's own effects. #3539's architecture keeps core as pure contracts. - Reverts the prompt-rail assertion loosening (ef1391344): it weakened #3121 coverage, added trailing whitespace, and did not fix the hosted failure. Generated-by: Claude Code --- apps/desktop/README.md | 7 +- apps/desktop/scripts/dev-app-runtime.mjs | 49 ++++++++-- apps/desktop/scripts/dev-app-runtime.test.mjs | 82 +++++++++++++--- apps/desktop/src/main/dev-profile-owner.ts | 61 ++++++++++++ apps/desktop/src/main/main.ts | 8 +- .../dev-single-instance-owner.test.ts | 60 +++++++----- .../core/src/dev-single-instance-owner.ts | 98 +++++++------------ 7 files changed, 249 insertions(+), 116 deletions(-) create mode 100644 apps/desktop/src/main/dev-profile-owner.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ed684f6e03..596908ece4 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -94,8 +94,11 @@ 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. When a launch loses the race, the conflict message names the holder from Chromium's own `SingletonLock` record (hostname -and PID) while that PID is still alive; a stale symlink, another machine's -record, or anything unresolvable falls back to the generic wording. +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 2ab27606f0..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,16 +215,46 @@ 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 shared default, matching launch behavior. - * Empty string when nothing trustworthy resolves, so callers fall back to the - * generic wording verbatim. + * `--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 userDataDir = explicitUserDataDir ?? options.userDataDir ?? DEV_USER_DATA_DIR; + 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; @@ -232,9 +263,13 @@ export async function developmentProfileConflictDetail(options = {}) { } catch { return ''; } - if (typeof mod?.resolveLiveDevProfileOwner !== 'function') return ''; + if (typeof mod?.resolveLiveDevProfileOwnerFromTarget !== 'function') return ''; try { - const owner = mod.resolveLiveDevProfileOwner(userDataDir); + 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 ''; diff --git a/apps/desktop/scripts/dev-app-runtime.test.mjs b/apps/desktop/scripts/dev-app-runtime.test.mjs index f7e279feb2..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 () => { @@ -337,50 +340,94 @@ test('an absorbed launch without a resolvable holder keeps the generic wording', 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: '/profile', + userDataDir: dir, + localHostname: 'mac.local', loader: async () => ({ - resolveLiveDevProfileOwner: (dir) => - dir === '/profile' ? { hostname: 'mac.local', pid: 739, isLocalHost: true } : undefined, + 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 shared default profile', async () => { +test('an explicit --user-data-dir wins over the platform default', async () => { const { developmentProfileConflictDetail } = await import('./dev-app-runtime.mjs'); - const seen = []; + const explicitDir = makeLockDir('explicit-box-5'); + const defaultDir = makeLockDir('default-box-9'); const detail = await developmentProfileConflictDetail({ - argv: ['--user-data-dir=/explicit/dir', '--other'], + argv: [`--user-data-dir=${explicitDir}`, '--other'], + userDataDir: defaultDir, loader: async () => ({ - resolveLiveDevProfileOwner: (dir) => { - seen.push(dir); - return { hostname: 'mac.local', pid: 739, isLocalHost: true }; - }, + resolveLiveDevProfileOwnerFromTarget: (target, deps) => + target === 'explicit-box-5' ? { hostname: 'explicit-box', pid: 5, isLocalHost: false } : undefined, describeDevProfileOwner: (owner) => `PID ${owner.pid}`, }), }); - assert.deepEqual(seen, ['/explicit/dir']); - assert.equal(detail, ' The holder appears to be PID 739.'); + 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({ loader: failingLoader }), ''); + assert.equal( + await developmentProfileConflictDetail({ userDataDir: dir, loader: failingLoader }), + '', + ); // A module without the expected surface degrades the same way. - assert.equal(await developmentProfileConflictDetail({ loader: async () => ({}) }), ''); + assert.equal( + await developmentProfileConflictDetail({ userDataDir: dir, loader: async () => ({}) }), + '', + ); // So does a resolver that throws. assert.equal( await developmentProfileConflictDetail({ - userDataDir: '/profile', + userDataDir: dir, loader: async () => ({ - resolveLiveDevProfileOwner: () => { + resolveLiveDevProfileOwnerFromTarget: () => { throw new Error('boom'); }, describeDevProfileOwner: () => '', @@ -388,6 +435,9 @@ test('conflict detail degrades to empty on any resolution failure', async () => }), '', ); + // 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 () => { 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 0283f25753..524a4cae98 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -23,13 +23,13 @@ import { developmentLaunchResultFile, shouldShowLoserDialog, } from '@maka/core/dev-single-instance'; -import { - describeDevProfileOwner, - resolveLiveDevProfileOwner, -} from '@maka/core/dev-single-instance-owner'; 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, diff --git a/packages/core/src/__tests__/dev-single-instance-owner.test.ts b/packages/core/src/__tests__/dev-single-instance-owner.test.ts index c445692717..8ab80527a6 100644 --- a/packages/core/src/__tests__/dev-single-instance-owner.test.ts +++ b/packages/core/src/__tests__/dev-single-instance-owner.test.ts @@ -21,7 +21,7 @@ import { test } from 'node:test'; import { describeDevProfileOwner, parseDevProfileLockTarget, - resolveLiveDevProfileOwner, + resolveLiveDevProfileOwnerFromTarget, } from '../dev-single-instance-owner.js'; test('parse reads hostname and pid by splitting from the end', () => { @@ -39,11 +39,15 @@ test('parse keeps dashed hostnames intact because the pid is the final segment', }); }); -test('parse takes the final path segment of path-shaped targets', () => { - assert.deepEqual( +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'), - { hostname: 'Maka Dev-192.168.0.2', pid: 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', () => { @@ -59,9 +63,8 @@ test('parse rejects malformed targets instead of guessing', () => { const alive = () => true; const dead = () => false; -test('resolve reports a live local holder from an injected lock target', () => { - const owner = resolveLiveDevProfileOwner('/profile', { - readLockTarget: () => 'mac.local-739', +test('resolve reports a live local holder from a lock target', () => { + const owner = resolveLiveDevProfileOwnerFromTarget('mac.local-739', { liveness: alive, localHostname: 'Mac.Local', }); @@ -69,47 +72,50 @@ test('resolve reports a live local holder from an injected lock target', () => { assert.equal(describeDevProfileOwner(owner!), 'PID 739 on this machine'); }); -test('resolve flags foreign-host records without treating them as local pids', () => { - const owner = resolveLiveDevProfileOwner('/profile', { - readLockTarget: () => 'other-box-739', - liveness: alive, +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 symlink whose pid is gone', () => { - const owner = resolveLiveDevProfileOwner('/profile', { - readLockTarget: () => 'mac.local-739', +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 on unreadable, missing, or malformed locks', () => { +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( - resolveLiveDevProfileOwner('/profile', { readLockTarget: () => undefined }), - undefined, - ); - assert.equal( - resolveLiveDevProfileOwner('/profile', { - readLockTarget: () => { - throw new Error('ENOENT'); - }, - }), + resolveLiveDevProfileOwnerFromTarget('mac.local-739', { liveness: alive }), undefined, ); +}); + +test('resolve degrades to undefined on malformed targets', () => { assert.equal( - resolveLiveDevProfileOwner('/profile', { readLockTarget: () => 'garbage' }), + 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 = resolveLiveDevProfileOwner('/profile', { - readLockTarget: () => 'mac.local-739', + const owner = resolveLiveDevProfileOwnerFromTarget('mac.local-739', { liveness: () => { throw new Error('boom'); }, diff --git a/packages/core/src/dev-single-instance-owner.ts b/packages/core/src/dev-single-instance-owner.ts index 793940b460..45476e4d56 100644 --- a/packages/core/src/dev-single-instance-owner.ts +++ b/packages/core/src/dev-single-instance-owner.ts @@ -19,14 +19,9 @@ // 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 `-`. The symlink alone is not -// evidence — it survives SIGKILL — so a holder is reported only while its PID -// is still alive, and any inconsistency degrades to "unknown" so every caller -// can fall back to the generic conflict wording. - -import { readlinkSync } from 'node:fs'; -import { hostname } from 'node:os'; -import process from 'node:process'; +// 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 { @@ -35,85 +30,73 @@ export interface DevProfileOwnerRecord { } /** - * Parse the SingletonLock symlink 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. Returns undefined - * for anything malformed rather than guessing. + * 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; - // Defensive: older layouts are documented with a path-shaped target; take - // the final path segment either way. - const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\')); - const name = separator >= 0 ? trimmed.slice(separator + 1) : trimmed; - const dash = name.lastIndexOf('-'); + if (trimmed.includes('/') || trimmed.includes('\\')) return undefined; + const dash = trimmed.lastIndexOf('-'); if (dash <= 0) return undefined; - const pidText = name.slice(dash + 1); + 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 = name.slice(0, dash); + const hostnamePart = trimmed.slice(0, dash); if (hostnamePart.length === 0) return undefined; return { hostname: hostnamePart, pid }; } /** - * Whether the PID still exists. `EPERM` counts as alive: an existing process - * owned by another user is a holder we may not signal, not an empty slot. + * 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; -function defaultLivenessProbe(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException)?.code === 'EPERM'; - } -} - -/** A SingletonLock whose recorded PID is still alive. */ +/** 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 { - /** Defaults to reading `/SingletonLock`. */ - readLockTarget?(): string | undefined; - liveness?: DevProfileLivenessProbe; - /** Defaults to this machine's hostname. */ + /** 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; } /** - * Resolve the profile holder from the lock's own record, or undefined when - * there is nothing trustworthy to report: no readable symlink, a malformed - * target, or a PID that no longer exists. A dead PID means the symlink is - * stale debris (the holder died without Cleanup()) — reportable as no holder. + * 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 resolveLiveDevProfileOwner( - userDataDir: string, +export function resolveLiveDevProfileOwnerFromTarget( + target: string, deps: DevProfileOwnerResolutionDeps = {}, ): DevProfileOwner | undefined { - let target: string | undefined; - try { - target = deps.readLockTarget?.() ?? readTarget(userDataDir); - } catch { - return undefined; - } - if (target === undefined) return undefined; const record = parseDevProfileLockTarget(target); if (!record) return undefined; - const liveness = deps.liveness ?? defaultLivenessProbe; - try { - if (!liveness(record.pid)) return undefined; - } catch { - 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; + } } - const local = (deps.localHostname ?? hostname()).toLowerCase(); - return { ...record, isLocalHost: record.hostname.toLowerCase() === local }; + return { ...record, isLocalHost }; } /** Human-readable holder identity for embedding in conflict messages. */ @@ -122,8 +105,3 @@ export function describeDevProfileOwner(owner: DevProfileOwner): string { ? `PID ${owner.pid} on this machine` : `PID ${owner.pid} on host "${owner.hostname}"`; } - -function readTarget(userDataDir: string): string | undefined { - const target = readlinkSync(`${userDataDir}/SingletonLock`, 'utf8'); - return target.length > 0 ? target : undefined; -}