Skip to content

Commit a7cdcfa

Browse files
GenKerenskyclaude
andcommitted
fix(desktop): make AppImage restarts come back on Linux
`app.relaunch()` re-execs `process.execPath`, which under AppImage points inside the FUSE mount that is unmounted as the process exits. Every restart therefore died with "Cannot mount AppImage, please check your FUSE setup." — the app quit and never returned, with nothing on screen to say why. The AppImage path now schedules a detached, delayed re-exec of `$APPIMAGE` after the current process exits, anchored at `cwd: /` so the helper is not itself holding the mount. The spawn is awaited, so a failure to even start the helper is logged instead of falling through to `exit(0)` and vanishing. The single-instance lock is released only once the relaunch is committed, so a failure leaves the app running *with* the lock rather than letting the next launch open a second instance. Verified against a real packaged AppImage (genuine FUSE mount) across two relaunch cycles: every relaunch logged `kind: appimage-delayed` and the app came back, with zero mount errors. That testing surfaced a second bug, fixed here too. The restart worked, but each cycle stranded the previous mount with its runtime parked in `fuse_dev_do_read` forever. Chromium keeps fds open without CLOEXEC and `spawn` cannot close them, so the detached helper inherited nine of them — `app.asar`, `icudtl.dat`, the `.pak` files, the mount directory — pinning a mount nothing needed. The helper now closes every fd above 2 before sleeping. After the fix: one mount and one runtime process across both cycles. The fd cleanup is emitted only under bash. dash — `/bin/sh` on Debian and Ubuntu — parses `exec 10>&-` as a command named `10`, and a failed `exec` terminates a non-interactive shell on the spot, which would kill the helper before the re-exec and lose the app permanently. Verified in `debian:stable-slim`. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014UZ1f7q6ZVbn6uqWgWRzh9
1 parent b125b76 commit a7cdcfa

10 files changed

Lines changed: 439 additions & 6 deletions

‎apps/desktop/src/app/DesktopAppIdentity.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const makeElectronAppLayer = (calls: ElectronAppCalls) =>
5656
setAppUserModelId: () => Effect.void,
5757
requestSingleInstanceLock: Effect.succeed(true),
5858
getAppMetrics: Effect.succeed([]),
59+
releaseSingleInstanceLock: Effect.void,
5960
isDefaultProtocolClient: () => Effect.succeed(false),
6061
setAsDefaultProtocolClient: () => Effect.succeed(true),
6162
setDesktopName: () => Effect.void,
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// @effect-diagnostics nodeBuiltinImport:off - Skip predicate runs at collection time, outside any Effect runtime.
2+
import * as NodeFS from "node:fs";
3+
4+
import { assert, describe, it } from "@effect/vitest";
5+
import * as Effect from "effect/Effect";
6+
7+
import {
8+
buildAppImageRelaunchShellCommand,
9+
posixShellSingleQuote,
10+
resolveDesktopRelaunchPlan,
11+
} from "./resolveDesktopRelaunchOptions.ts";
12+
import { scheduleAppImageRelaunch } from "./scheduleAppImageRelaunch.ts";
13+
14+
describe("resolveDesktopRelaunchPlan", () => {
15+
it.effect("schedules a delayed AppImage re-exec with flag argv only", () =>
16+
Effect.sync(() => {
17+
const plan = resolveDesktopRelaunchPlan({
18+
appImagePath: "/home/user/T3-Code-0.0.28-x86_64.AppImage",
19+
execPath: "/tmp/.mount_t3_codXXXX/t3code",
20+
argv: [
21+
"/tmp/.mount_t3_codXXXX/t3code",
22+
"--no-sandbox",
23+
"/tmp/.mount_t3_codXXXX/resources/app.asar",
24+
],
25+
});
26+
27+
assert.deepEqual(plan, {
28+
kind: "appimage-delayed",
29+
appImagePath: "/home/user/T3-Code-0.0.28-x86_64.AppImage",
30+
args: ["--no-sandbox"],
31+
delayMs: 1_000,
32+
});
33+
}),
34+
);
35+
36+
it.effect("trims APPIMAGE and falls back to electron relaunch when empty", () =>
37+
Effect.sync(() => {
38+
assert.deepEqual(
39+
resolveDesktopRelaunchPlan({
40+
appImagePath: " /opt/T3-Code.AppImage ",
41+
execPath: "/tmp/.mount/app",
42+
argv: ["/tmp/.mount/app"],
43+
}),
44+
{
45+
kind: "appimage-delayed",
46+
appImagePath: "/opt/T3-Code.AppImage",
47+
args: [],
48+
delayMs: 1_000,
49+
},
50+
);
51+
52+
assert.deepEqual(
53+
resolveDesktopRelaunchPlan({
54+
appImagePath: " ",
55+
execPath: "/usr/bin/t3-code",
56+
argv: ["/usr/bin/t3-code", "--flag"],
57+
}),
58+
{
59+
kind: "electron",
60+
execPath: "/usr/bin/t3-code",
61+
args: ["--flag"],
62+
},
63+
);
64+
}),
65+
);
66+
67+
it.effect("preserves packaged non-AppImage exec path and argv", () =>
68+
Effect.sync(() => {
69+
const plan = resolveDesktopRelaunchPlan({
70+
appImagePath: null,
71+
execPath: "/Applications/T3 Code.app/Contents/MacOS/T3 Code",
72+
argv: ["/Applications/T3 Code.app/Contents/MacOS/T3 Code", "--inspect"],
73+
});
74+
75+
assert.deepEqual(plan, {
76+
kind: "electron",
77+
execPath: "/Applications/T3 Code.app/Contents/MacOS/T3 Code",
78+
args: ["--inspect"],
79+
});
80+
}),
81+
);
82+
});
83+
84+
describe("buildAppImageRelaunchShellCommand", () => {
85+
it.effect("quotes the AppImage path and delays before exec", () =>
86+
Effect.sync(() => {
87+
const command = buildAppImageRelaunchShellCommand({
88+
appImagePath: "/home/user/T3 Code.AppImage",
89+
args: ["--no-sandbox"],
90+
delayMs: 1_000,
91+
});
92+
93+
assert.equal(
94+
command,
95+
`sleep 1 && exec ${posixShellSingleQuote("/home/user/T3 Code.AppImage")} ${posixShellSingleQuote("--no-sandbox")}`,
96+
);
97+
98+
assert.equal(posixShellSingleQuote("it's"), `'it'\\''s'`);
99+
}),
100+
);
101+
102+
it.effect("omits the fd cleanup unless the shell is known to handle it", () =>
103+
Effect.sync(() => {
104+
const base = {
105+
appImagePath: "/opt/T3.AppImage",
106+
args: [],
107+
delayMs: 1_000,
108+
} as const;
109+
110+
// dash reads `exec 10>&-` as a command named "10" and a failed exec kills
111+
// a non-interactive shell, so emitting this for /bin/sh would abort the
112+
// helper before the re-exec and lose the app entirely.
113+
assert.isFalse(buildAppImageRelaunchShellCommand(base).includes("/proc/$$/fd"));
114+
115+
const withCleanup = buildAppImageRelaunchShellCommand({
116+
...base,
117+
closeInheritedFds: true,
118+
});
119+
// Must run before the sleep, so the outgoing mount can be released during it.
120+
assert.isTrue(withCleanup.startsWith("for fd in /proc/$$/fd/*;"), withCleanup);
121+
assert.isTrue(
122+
withCleanup.indexOf("exec $n>&-") < withCleanup.indexOf("sleep 1"),
123+
withCleanup,
124+
);
125+
}),
126+
);
127+
128+
it.effect("records a relaunch failure without letting the log block the exec", () =>
129+
Effect.sync(() => {
130+
const command = buildAppImageRelaunchShellCommand({
131+
appImagePath: "/opt/T3.AppImage",
132+
args: [],
133+
delayMs: 1_000,
134+
logPath: "/home/user/.t3/logs/relaunch.log",
135+
});
136+
137+
// Wrapping the group in `2>>log` would abort the whole helper when the
138+
// log directory is missing, and would also follow a *successful* exec
139+
// into the relaunched app. Guard with a test and append afterwards.
140+
assert.notInclude(command, "} 2>>");
141+
assert.include(command, `[ -x ${posixShellSingleQuote("/opt/T3.AppImage")} ] && exec `);
142+
// A bad log path must not matter once we are past the exec.
143+
assert.isTrue(
144+
command.endsWith(
145+
`>>${posixShellSingleQuote("/home/user/.t3/logs/relaunch.log")} 2>/dev/null`,
146+
),
147+
command,
148+
);
149+
}),
150+
);
151+
});
152+
153+
describe("scheduleAppImageRelaunch", () => {
154+
// Really spawns the helper. The code path is POSIX-only by construction, so
155+
// gate on the shell it needs rather than on a platform name — on a Windows
156+
// dev machine (a supported setup) this would otherwise fail with ENOENT.
157+
it.effect.skipIf(!NodeFS.existsSync("/bin/sh"))(
158+
"resolves only once the detached helper has actually spawned",
159+
() =>
160+
// Resolves on the `spawn` event, well before the helper's sleep elapses.
161+
// The caller depends on this to know it is safe to release the
162+
// single-instance lock and exit rather than vanishing on a failed spawn.
163+
Effect.promise(() =>
164+
scheduleAppImageRelaunch({
165+
kind: "appimage-delayed",
166+
appImagePath: "/bin/true",
167+
args: [],
168+
delayMs: 200,
169+
}),
170+
),
171+
);
172+
});

‎apps/desktop/src/app/DesktopLifecycle.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ describe("DesktopLifecycle", () => {
3131
setAppUserModelId: () => Effect.void,
3232
requestSingleInstanceLock: Effect.succeed(true),
3333
getAppMetrics: Effect.succeed([]),
34+
releaseSingleInstanceLock: Effect.void,
3435
isDefaultProtocolClient: () => Effect.succeed(false),
3536
setAsDefaultProtocolClient: () => Effect.succeed(true),
3637
setDesktopName: () => Effect.void,

‎apps/desktop/src/app/DesktopLifecycle.ts‎

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import * as ElectronApp from "../electron/ElectronApp.ts";
1414
import * as ElectronTheme from "../electron/ElectronTheme.ts";
1515
import * as DesktopState from "./DesktopState.ts";
1616
import * as DesktopWindow from "../window/DesktopWindow.ts";
17+
import { resolveDesktopRelaunchPlan } from "./resolveDesktopRelaunchOptions.ts";
18+
import { scheduleAppImageRelaunch } from "./scheduleAppImageRelaunch.ts";
1719

1820
export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass<DesktopLifecycleRelaunchError>()(
1921
"DesktopLifecycleRelaunchError",
@@ -27,6 +29,8 @@ export class DesktopLifecycleRelaunchError extends Schema.TaggedErrorClass<Deskt
2729
}
2830
}
2931

32+
export { resolveDesktopRelaunchPlan } from "./resolveDesktopRelaunchOptions.ts";
33+
3034
export type DesktopLifecycleRuntimeServices =
3135
| DesktopEnvironment.DesktopEnvironment
3236
| DesktopShutdown.DesktopShutdown
@@ -154,16 +158,65 @@ export const make = DesktopLifecycle.of({
154158
yield* electronApp.exit(75);
155159
return;
156160
}
157-
yield* electronApp.relaunch({
161+
const relaunchPlan = resolveDesktopRelaunchPlan({
162+
// AppImage runtime injects APPIMAGE; see resolveDesktopRelaunchPlan.
163+
appImagePath: process.env.APPIMAGE,
158164
execPath: process.execPath,
159-
args: process.argv.slice(1),
165+
argv: process.argv,
166+
});
167+
yield* logLifecycleInfo("desktop relaunch exec", {
168+
reason,
169+
kind: relaunchPlan.kind,
170+
execPath:
171+
relaunchPlan.kind === "appimage-delayed"
172+
? relaunchPlan.appImagePath
173+
: relaunchPlan.execPath,
174+
argumentCount: relaunchPlan.args.length,
175+
...(relaunchPlan.kind === "appimage-delayed" ? { delayMs: relaunchPlan.delayMs } : {}),
160176
});
177+
if (relaunchPlan.kind === "appimage-delayed") {
178+
// Delayed shell re-exec avoids racing the AppImage FUSE unmount, which
179+
// otherwise surfaces "Cannot mount AppImage, please check your FUSE setup."
180+
// Await the spawn: a failure here must not fall through to exit(0), or
181+
// the app just disappears and never comes back.
182+
yield* Effect.tryPromise({
183+
try: () =>
184+
scheduleAppImageRelaunch(
185+
relaunchPlan,
186+
process.env,
187+
// The re-exec fires after we are gone, so its own failures can
188+
// only surface here.
189+
environment.path.join(environment.logDir, "relaunch.log"),
190+
),
191+
catch: (cause) => new DesktopLifecycleRelaunchError({ reason, cause }),
192+
});
193+
} else {
194+
yield* electronApp.relaunch({
195+
execPath: relaunchPlan.execPath,
196+
args: relaunchPlan.args,
197+
});
198+
}
199+
// Only give up the lock once the relaunch is committed. Neither path has
200+
// started the next process yet (app.relaunch defers its spawn to exit,
201+
// the AppImage helper sleeps first), so releasing here still beats the
202+
// successor's requestSingleInstanceLock().
203+
yield* electronApp.releaseSingleInstanceLock;
161204
yield* electronApp.exit(0);
162205
}).pipe(
163-
Effect.catchCause((cause) => {
164-
const error = new DesktopLifecycleRelaunchError({ reason, cause });
165-
return logLifecycleError(error.message, { error });
166-
}),
206+
Effect.catchCause((cause) =>
207+
Effect.gen(function* () {
208+
const error = new DesktopLifecycleRelaunchError({ reason, cause });
209+
yield* logLifecycleError(error.message, { error });
210+
// By this point `quitting` is set and requestDesktopShutdownAndWait
211+
// has already torn the backend down, so there is no working app left
212+
// to return to. Staying alive would just hold the single-instance
213+
// lock on a dead instance, and every later launch would focus *this*
214+
// window instead of starting a usable one. Hand the lock back and
215+
// exit non-zero so the next launch comes up clean.
216+
yield* electronApp.releaseSingleInstanceLock.pipe(Effect.ignore);
217+
yield* electronApp.exit(1);
218+
}),
219+
),
167220
Effect.forkDetach,
168221
Effect.asVoid,
169222
);
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
export interface DesktopElectronRelaunchPlan {
2+
readonly kind: "electron";
3+
readonly execPath: string;
4+
readonly args: string[];
5+
}
6+
7+
export interface DesktopAppImageRelaunchPlan {
8+
readonly kind: "appimage-delayed";
9+
readonly appImagePath: string;
10+
readonly args: string[];
11+
/** Delay before re-exec so the current AppImage can unmount its FUSE image. */
12+
readonly delayMs: number;
13+
}
14+
15+
export type DesktopRelaunchPlan = DesktopElectronRelaunchPlan | DesktopAppImageRelaunchPlan;
16+
17+
export const DEFAULT_APPIMAGE_RELAUNCH_DELAY_MS = 1_000;
18+
19+
/**
20+
* Resolve how the desktop process should restart for the current packaging mode.
21+
*
22+
* AppImage mounts the payload under a temporary directory and sets `APPIMAGE`
23+
* to the outer `.AppImage` path. `process.execPath` / `process.argv` point at
24+
* the mount, which is unmounted when the process exits — so relaunching with
25+
* those paths (or racing a second FUSE mount while the first unmounts) fails
26+
* with "Cannot mount AppImage, please check your FUSE setup."
27+
*
28+
* For AppImage we schedule a delayed re-exec of `$APPIMAGE` after the current
29+
* process exits. Keep only flag-style argv entries (e.g. `--no-sandbox` from
30+
* Gear Lever / Flatpak launches).
31+
*/
32+
export function resolveDesktopRelaunchPlan(input: {
33+
readonly appImagePath?: string | null | undefined;
34+
readonly execPath: string;
35+
readonly argv: readonly string[];
36+
readonly appImageDelayMs?: number;
37+
}): DesktopRelaunchPlan {
38+
const appImagePath = input.appImagePath?.trim();
39+
if (appImagePath) {
40+
return {
41+
kind: "appimage-delayed",
42+
appImagePath,
43+
args: input.argv.slice(1).filter((arg) => arg.startsWith("--")),
44+
delayMs: input.appImageDelayMs ?? DEFAULT_APPIMAGE_RELAUNCH_DELAY_MS,
45+
};
46+
}
47+
48+
return {
49+
kind: "electron",
50+
execPath: input.execPath,
51+
args: input.argv.slice(1),
52+
};
53+
}
54+
55+
/** POSIX-safe single-quoting for `/bin/sh -c` command construction. */
56+
export function posixShellSingleQuote(value: string): string {
57+
return `'${value.replaceAll("'", `'\\''`)}'`;
58+
}
59+
60+
/**
61+
* Drop file descriptors inherited from the exiting app before re-exec.
62+
*
63+
* Chromium keeps fds open without CLOEXEC (`app.asar`, `icudtl.dat`, the `.pak`
64+
* files, the mount directory itself), and `spawn` cannot close them for us.
65+
* Inherited through this helper's `exec`, they keep the *outgoing* AppImage's
66+
* FUSE mount busy forever: its runtime process parks in `fuse_dev_do_read` and
67+
* never unmounts, so every restart strands a mount and a process.
68+
*
69+
* Only emitted for bash. Chromium's fds are well above 9, and dash — `/bin/sh`
70+
* on Debian and Ubuntu — does not accept multi-digit fd numbers in
71+
* redirections: it reads `exec 10>&-` as running a command named `10`, and a
72+
* failed `exec` terminates a non-interactive shell on the spot. That kills the
73+
* helper before it reaches the re-exec, leaving the app gone for good. `eval`
74+
* does not help, because this is a failed command rather than a parse error,
75+
* so `|| true` never runs.
76+
*/
77+
const CLOSE_INHERITED_FDS_SNIPPET =
78+
'for fd in /proc/$$/fd/*; do n=${fd##*/}; case "$n" in 0|1|2) continue;; esac; ' +
79+
'eval "exec $n>&-" 2>/dev/null || true; done';
80+
81+
export function buildAppImageRelaunchShellCommand(input: {
82+
readonly appImagePath: string;
83+
readonly args: readonly string[];
84+
readonly delayMs: number;
85+
/** Only safe under bash; see {@link CLOSE_INHERITED_FDS_SNIPPET}. */
86+
readonly closeInheritedFds?: boolean;
87+
/** Captures the helper's stderr, so a failed delayed `exec` is diagnosable. */
88+
readonly logPath?: string | undefined;
89+
}): string {
90+
// Whole seconds only: POSIX specifies `sleep` as taking an integer, and a
91+
// /bin/sh whose sleep rejects "1.0" would short-circuit the `&&` and never
92+
// exec — an app that quits and never returns, which is the failure this
93+
// helper exists to avoid. The spawn succeeds either way, so nothing would
94+
// report it.
95+
const sleepSeconds = Math.max(1, Math.ceil(input.delayMs / 1_000));
96+
const quotedPath = posixShellSingleQuote(input.appImagePath);
97+
const quotedArgs = input.args.map(posixShellSingleQuote).join(" ");
98+
const execTarget = quotedArgs.length > 0 ? `${quotedPath} ${quotedArgs}` : quotedPath;
99+
// The exec happens after we have exited, so a missing or non-executable
100+
// AppImage can only be reported by leaving a trace behind. Test for it and
101+
// append a line *after* the exec instead of redirecting the whole group:
102+
//
103+
// - `{ ...; } 2>>log` fails the redirection outright when the log's
104+
// directory is missing or unwritable, and the re-exec then never runs at
105+
// all — trading a diagnosable failure for a guaranteed one.
106+
// - that redirection also survives a *successful* exec, so the relaunched
107+
// app would spend its whole life appending Chromium stderr to an
108+
// unrotated file.
109+
//
110+
// `2>/dev/null` on the append keeps a bad log path from mattering, and the
111+
// exec'd process keeps the stdio it was always meant to have.
112+
const relaunch = input.logPath
113+
? `sleep ${sleepSeconds}; [ -x ${quotedPath} ] && exec ${execTarget}; ` +
114+
`echo ${posixShellSingleQuote(`T3 Code relaunch failed: ${input.appImagePath} is missing or not executable`)} ` +
115+
`>>${posixShellSingleQuote(input.logPath)} 2>/dev/null`
116+
: `sleep ${sleepSeconds} && exec ${execTarget}`;
117+
// Close first, then sleep: releasing the fds up front lets the outgoing
118+
// mount unmount *during* the delay rather than after it.
119+
return input.closeInheritedFds ? `${CLOSE_INHERITED_FDS_SNIPPET}; ${relaunch}` : relaunch;
120+
}

0 commit comments

Comments
 (0)