Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
74 changes: 70 additions & 4 deletions apps/desktop/scripts/dev-app-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.`,
);
});
});
Expand Down Expand Up @@ -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':
Expand Down
127 changes: 127 additions & 0 deletions apps/desktop/scripts/dev-app-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 = [];
Expand Down
19 changes: 13 additions & 6 deletions apps/desktop/scripts/dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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));
}
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/scripts/start-dev-app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/

import {
developmentProfileConflictDetail,
handleDevelopmentLaunchOutcome,
startDevelopmentApp,
waitForDevelopmentLaunchVerdict,
Expand Down Expand Up @@ -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) => {
Expand Down
61 changes: 61 additions & 0 deletions apps/desktop/src/main/dev-profile-owner.ts
Original file line number Diff line number Diff line change
@@ -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 };
Loading