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
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopAppIdentity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
getAppMetrics: Effect.succeed([]),
releaseSingleInstanceLock: Effect.void,
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
Expand Down
172 changes: 172 additions & 0 deletions apps/desktop/src/app/DesktopLifecycle.relaunch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// @effect-diagnostics nodeBuiltinImport:off - Skip predicate runs at collection time, outside any Effect runtime.
import * as NodeFS from "node:fs";

import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";

import {
buildAppImageRelaunchShellCommand,
posixShellSingleQuote,
resolveDesktopRelaunchPlan,
} from "./resolveDesktopRelaunchOptions.ts";
import { scheduleAppImageRelaunch } from "./scheduleAppImageRelaunch.ts";

describe("resolveDesktopRelaunchPlan", () => {
it.effect("schedules a delayed AppImage re-exec with flag argv only", () =>
Effect.sync(() => {
const plan = resolveDesktopRelaunchPlan({
appImagePath: "/home/user/T3-Code-0.0.28-x86_64.AppImage",
execPath: "/tmp/.mount_t3_codXXXX/t3code",
argv: [
"/tmp/.mount_t3_codXXXX/t3code",
"--no-sandbox",
"/tmp/.mount_t3_codXXXX/resources/app.asar",
],
});

assert.deepEqual(plan, {
kind: "appimage-delayed",
appImagePath: "/home/user/T3-Code-0.0.28-x86_64.AppImage",
args: ["--no-sandbox"],
delayMs: 1_000,
});
}),
);

it.effect("trims APPIMAGE and falls back to electron relaunch when empty", () =>
Effect.sync(() => {
assert.deepEqual(
resolveDesktopRelaunchPlan({
appImagePath: " /opt/T3-Code.AppImage ",
execPath: "/tmp/.mount/app",
argv: ["/tmp/.mount/app"],
}),
{
kind: "appimage-delayed",
appImagePath: "/opt/T3-Code.AppImage",
args: [],
delayMs: 1_000,
},
);

assert.deepEqual(
resolveDesktopRelaunchPlan({
appImagePath: " ",
execPath: "/usr/bin/t3-code",
argv: ["/usr/bin/t3-code", "--flag"],
}),
{
kind: "electron",
execPath: "/usr/bin/t3-code",
args: ["--flag"],
},
);
}),
);

it.effect("preserves packaged non-AppImage exec path and argv", () =>
Effect.sync(() => {
const plan = resolveDesktopRelaunchPlan({
appImagePath: null,
execPath: "/Applications/T3 Code.app/Contents/MacOS/T3 Code",
argv: ["/Applications/T3 Code.app/Contents/MacOS/T3 Code", "--inspect"],
});

assert.deepEqual(plan, {
kind: "electron",
execPath: "/Applications/T3 Code.app/Contents/MacOS/T3 Code",
args: ["--inspect"],
});
}),
);
});

describe("buildAppImageRelaunchShellCommand", () => {
it.effect("quotes the AppImage path and delays before exec", () =>
Effect.sync(() => {
const command = buildAppImageRelaunchShellCommand({
appImagePath: "/home/user/T3 Code.AppImage",
args: ["--no-sandbox"],
delayMs: 1_000,
});

assert.equal(
command,
`sleep 1 && exec ${posixShellSingleQuote("/home/user/T3 Code.AppImage")} ${posixShellSingleQuote("--no-sandbox")}`,
);

assert.equal(posixShellSingleQuote("it's"), `'it'\\''s'`);
}),
);

it.effect("omits the fd cleanup unless the shell is known to handle it", () =>
Effect.sync(() => {
const base = {
appImagePath: "/opt/T3.AppImage",
args: [],
delayMs: 1_000,
} as const;

// dash reads `exec 10>&-` as a command named "10" and a failed exec kills
// a non-interactive shell, so emitting this for /bin/sh would abort the
// helper before the re-exec and lose the app entirely.
assert.isFalse(buildAppImageRelaunchShellCommand(base).includes("/proc/$$/fd"));

const withCleanup = buildAppImageRelaunchShellCommand({
...base,
closeInheritedFds: true,
});
// Must run before the sleep, so the outgoing mount can be released during it.
assert.isTrue(withCleanup.startsWith("for fd in /proc/$$/fd/*;"), withCleanup);
assert.isTrue(
withCleanup.indexOf("exec $n>&-") < withCleanup.indexOf("sleep 1"),
withCleanup,
);
}),
);

it.effect("records a relaunch failure without letting the log block the exec", () =>
Effect.sync(() => {
const command = buildAppImageRelaunchShellCommand({
appImagePath: "/opt/T3.AppImage",
args: [],
delayMs: 1_000,
logPath: "/home/user/.t3/logs/relaunch.log",
});

// Wrapping the group in `2>>log` would abort the whole helper when the
// log directory is missing, and would also follow a *successful* exec
// into the relaunched app. Guard with a test and append afterwards.
assert.notInclude(command, "} 2>>");
assert.include(command, `[ -x ${posixShellSingleQuote("/opt/T3.AppImage")} ] && exec `);
// A bad log path must not matter once we are past the exec.
assert.isTrue(
command.endsWith(
`>>${posixShellSingleQuote("/home/user/.t3/logs/relaunch.log")} 2>/dev/null`,
),
command,
);
}),
);
});

describe("scheduleAppImageRelaunch", () => {
// Really spawns the helper. The code path is POSIX-only by construction, so
// gate on the shell it needs rather than on a platform name — on a Windows
// dev machine (a supported setup) this would otherwise fail with ENOENT.
it.effect.skipIf(!NodeFS.existsSync("/bin/sh"))(
"resolves only once the detached helper has actually spawned",
() =>
// Resolves on the `spawn` event, well before the helper's sleep elapses.
// The caller depends on this to know it is safe to release the
// single-instance lock and exit rather than vanishing on a failed spawn.
Effect.promise(() =>
scheduleAppImageRelaunch({
kind: "appimage-delayed",
appImagePath: "/bin/true",
args: [],
delayMs: 200,
}),
),
);
});
1 change: 1 addition & 0 deletions apps/desktop/src/app/DesktopLifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("DesktopLifecycle", () => {
setAppUserModelId: () => Effect.void,
requestSingleInstanceLock: Effect.succeed(true),
getAppMetrics: Effect.succeed([]),
releaseSingleInstanceLock: Effect.void,
isDefaultProtocolClient: () => Effect.succeed(false),
setAsDefaultProtocolClient: () => Effect.succeed(true),
setDesktopName: () => Effect.void,
Expand Down
65 changes: 59 additions & 6 deletions apps/desktop/src/app/DesktopLifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronTheme from "../electron/ElectronTheme.ts";
import * as DesktopState from "./DesktopState.ts";
import * as DesktopWindow from "../window/DesktopWindow.ts";
import { resolveDesktopRelaunchPlan } from "./resolveDesktopRelaunchOptions.ts";
import { scheduleAppImageRelaunch } from "./scheduleAppImageRelaunch.ts";

export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass<DesktopLifecycleRelaunchError>()(
"DesktopLifecycleRelaunchError",
Expand All @@ -27,6 +29,8 @@ export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass<Deskt
}
}

export { resolveDesktopRelaunchPlan } from "./resolveDesktopRelaunchOptions.ts";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This re-export has no consumers (DesktopLifecycle.ts itself and DesktopLifecycle.relaunch.test.ts both import from ./resolveDesktopRelaunchOptions.ts), so it only adds a second import path for the helper and interrupts the canonical module order (imports, errors, Context.Service tag, make, layer). Consider dropping it and letting consumers import the helper from its own module.

-export { resolveDesktopRelaunchPlan } from "./resolveDesktopRelaunchOptions.ts";
-

Posted via Macroscope — Effect Service Conventions


export type DesktopLifecycleRuntimeServices =
| DesktopEnvironment.DesktopEnvironment
| DesktopShutdown.DesktopShutdown
Expand Down Expand Up @@ -154,16 +158,65 @@ export const make = DesktopLifecycle.of({
yield* electronApp.exit(75);
return;
}
yield* electronApp.relaunch({
const relaunchPlan = resolveDesktopRelaunchPlan({
// AppImage runtime injects APPIMAGE; see resolveDesktopRelaunchPlan.
appImagePath: process.env.APPIMAGE,
execPath: process.execPath,
args: process.argv.slice(1),
argv: process.argv,
});
yield* logLifecycleInfo("desktop relaunch exec", {
reason,
kind: relaunchPlan.kind,
execPath:
relaunchPlan.kind === "appimage-delayed"
? relaunchPlan.appImagePath
: relaunchPlan.execPath,
argumentCount: relaunchPlan.args.length,
...(relaunchPlan.kind === "appimage-delayed" ? { delayMs: relaunchPlan.delayMs } : {}),
});
if (relaunchPlan.kind === "appimage-delayed") {
// Delayed shell re-exec avoids racing the AppImage FUSE unmount, which
// otherwise surfaces "Cannot mount AppImage, please check your FUSE setup."
// Await the spawn: a failure here must not fall through to exit(0), or
// the app just disappears and never comes back.
yield* Effect.tryPromise({
try: () =>
scheduleAppImageRelaunch(
relaunchPlan,
process.env,
// The re-exec fires after we are gone, so its own failures can
// only surface here.
environment.path.join(environment.logDir, "relaunch.log"),
),
catch: (cause) => new DesktopLifecycleRelaunchError({ reason, cause }),
});
} else {
yield* electronApp.relaunch({
execPath: relaunchPlan.execPath,
args: relaunchPlan.args,
});
}
// Only give up the lock once the relaunch is committed. Neither path has
// started the next process yet (app.relaunch defers its spawn to exit,
// the AppImage helper sleeps first), so releasing here still beats the
// successor's requestSingleInstanceLock().
yield* electronApp.releaseSingleInstanceLock;
yield* electronApp.exit(0);
}).pipe(
Effect.catchCause((cause) => {
const error = new DesktopLifecycleRelaunchError({ reason, cause });
return logLifecycleError(error.message, { error });
}),
Effect.catchCause((cause) =>
Effect.gen(function* () {
const error = new DesktopLifecycleRelaunchError({ reason, cause });
yield* logLifecycleError(error.message, { error });
// By this point `quitting` is set and requestDesktopShutdownAndWait
// has already torn the backend down, so there is no working app left
// to return to. Staying alive would just hold the single-instance
// lock on a dead instance, and every later launch would focus *this*
// window instead of starting a usable one. Hand the lock back and
// exit non-zero so the next launch comes up clean.
yield* electronApp.releaseSingleInstanceLock.pipe(Effect.ignore);
yield* electronApp.exit(1);
}),
),
Effect.forkDetach,
Effect.asVoid,
);
Expand Down
120 changes: 120 additions & 0 deletions apps/desktop/src/app/resolveDesktopRelaunchOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
export interface DesktopElectronRelaunchPlan {
readonly kind: "electron";
readonly execPath: string;
readonly args: string[];
}

export interface DesktopAppImageRelaunchPlan {
readonly kind: "appimage-delayed";
readonly appImagePath: string;
readonly args: string[];
/** Delay before re-exec so the current AppImage can unmount its FUSE image. */
readonly delayMs: number;
}

export type DesktopRelaunchPlan = DesktopElectronRelaunchPlan | DesktopAppImageRelaunchPlan;

export const DEFAULT_APPIMAGE_RELAUNCH_DELAY_MS = 1_000;

/**
* Resolve how the desktop process should restart for the current packaging mode.
*
* AppImage mounts the payload under a temporary directory and sets `APPIMAGE`
* to the outer `.AppImage` path. `process.execPath` / `process.argv` point at
* the mount, which is unmounted when the process exits — so relaunching with
* those paths (or racing a second FUSE mount while the first unmounts) fails
* with "Cannot mount AppImage, please check your FUSE setup."
*
* For AppImage we schedule a delayed re-exec of `$APPIMAGE` after the current
* process exits. Keep only flag-style argv entries (e.g. `--no-sandbox` from
* Gear Lever / Flatpak launches).
*/
export function resolveDesktopRelaunchPlan(input: {
readonly appImagePath?: string | null | undefined;
readonly execPath: string;
readonly argv: readonly string[];
readonly appImageDelayMs?: number;
}): DesktopRelaunchPlan {
const appImagePath = input.appImagePath?.trim();
if (appImagePath) {
Comment on lines +38 to +39

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High app/resolveDesktopRelaunchOptions.ts:38

resolveDesktopRelaunchPlan trims appImagePath and uses the trimmed value as the relaunch executable path. An AppImage whose real path contains leading or trailing whitespace is relaunched at a different, nonexistent path, so the current app exits but never comes back. Trim only to test whether the value is blank and preserve the original path for execution.

-  const appImagePath = input.appImagePath?.trim();
-  if (appImagePath) {
+  const trimmedAppImagePath = input.appImagePath?.trim();
+  if (trimmedAppImagePath) {
+    const appImagePath = input.appImagePath!;
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/app/resolveDesktopRelaunchOptions.ts around lines 38-39:

`resolveDesktopRelaunchPlan` trims `appImagePath` and uses the trimmed value as the relaunch executable path. An AppImage whose real path contains leading or trailing whitespace is relaunched at a different, nonexistent path, so the current app exits but never comes back. Trim only to test whether the value is blank and preserve the original path for execution.

return {
kind: "appimage-delayed",
appImagePath,
args: input.argv.slice(1).filter((arg) => arg.startsWith("--")),
delayMs: input.appImageDelayMs ?? DEFAULT_APPIMAGE_RELAUNCH_DELAY_MS,
};
}

return {
kind: "electron",
execPath: input.execPath,
args: input.argv.slice(1),
};
}

/** POSIX-safe single-quoting for `/bin/sh -c` command construction. */
export function posixShellSingleQuote(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`;
}

/**
* Drop file descriptors inherited from the exiting app before re-exec.
*
* Chromium keeps fds open without CLOEXEC (`app.asar`, `icudtl.dat`, the `.pak`
* files, the mount directory itself), and `spawn` cannot close them for us.
* Inherited through this helper's `exec`, they keep the *outgoing* AppImage's
* FUSE mount busy forever: its runtime process parks in `fuse_dev_do_read` and
* never unmounts, so every restart strands a mount and a process.
*
* Only emitted for bash. Chromium's fds are well above 9, and dash — `/bin/sh`
* on Debian and Ubuntu — does not accept multi-digit fd numbers in
* redirections: it reads `exec 10>&-` as running a command named `10`, and a
* failed `exec` terminates a non-interactive shell on the spot. That kills the
* helper before it reaches the re-exec, leaving the app gone for good. `eval`
* does not help, because this is a failed command rather than a parse error,
* so `|| true` never runs.
*/
const CLOSE_INHERITED_FDS_SNIPPET =
'for fd in /proc/$$/fd/*; do n=${fd##*/}; case "$n" in 0|1|2) continue;; esac; ' +
'eval "exec $n>&-" 2>/dev/null || true; done';

export function buildAppImageRelaunchShellCommand(input: {
readonly appImagePath: string;
readonly args: readonly string[];
readonly delayMs: number;
/** Only safe under bash; see {@link CLOSE_INHERITED_FDS_SNIPPET}. */
readonly closeInheritedFds?: boolean;
/** Captures the helper's stderr, so a failed delayed `exec` is diagnosable. */
readonly logPath?: string | undefined;
}): string {
// Whole seconds only: POSIX specifies `sleep` as taking an integer, and a
// /bin/sh whose sleep rejects "1.0" would short-circuit the `&&` and never
// exec — an app that quits and never returns, which is the failure this
// helper exists to avoid. The spawn succeeds either way, so nothing would
// report it.
const sleepSeconds = Math.max(1, Math.ceil(input.delayMs / 1_000));
const quotedPath = posixShellSingleQuote(input.appImagePath);
const quotedArgs = input.args.map(posixShellSingleQuote).join(" ");
const execTarget = quotedArgs.length > 0 ? `${quotedPath} ${quotedArgs}` : quotedPath;
// The exec happens after we have exited, so a missing or non-executable
// AppImage can only be reported by leaving a trace behind. Test for it and
// append a line *after* the exec instead of redirecting the whole group:
//
// - `{ ...; } 2>>log` fails the redirection outright when the log's
// directory is missing or unwritable, and the re-exec then never runs at
// all — trading a diagnosable failure for a guaranteed one.
// - that redirection also survives a *successful* exec, so the relaunched
// app would spend its whole life appending Chromium stderr to an
// unrotated file.
//
// `2>/dev/null` on the append keeps a bad log path from mattering, and the
// exec'd process keeps the stdio it was always meant to have.
const relaunch = input.logPath
? `sleep ${sleepSeconds}; [ -x ${quotedPath} ] && exec ${execTarget}; ` +
`echo ${posixShellSingleQuote(`T3 Code relaunch failed: ${input.appImagePath} is missing or not executable`)} ` +
`>>${posixShellSingleQuote(input.logPath)} 2>/dev/null`
: `sleep ${sleepSeconds} && exec ${execTarget}`;
// Close first, then sleep: releasing the fds up front lets the outgoing
// mount unmount *during* the delay rather than after it.
return input.closeInheritedFds ? `${CLOSE_INHERITED_FDS_SNIPPET}; ${relaunch}` : relaunch;
}
Loading
Loading