authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-25 00:12:41-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-25 00:12:41-07:00
log86054b6df2c5e38b6e27b980eae2716bfe1da202
tree58f6cff57cbfe6caa930a8093b042d5ebbfe7108
parent13d6b30515e9d44bb4cc2d223b8b3179c2ba4c79
signature Signed by PGP key B5690EEEBB952194

fix(desktop): `t3 app` keeps working after a second desktop app quits (#13585)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

2 files changed, 206 insertions(+), 39 deletions(-)

apps/desktop/src/app/DesktopAppActivation.test.ts+67
...@@ -44,6 +44,25 @@ function request(requestId: string, platform: NodeJS.Platform): DesktopAppActiva...@@ -44,6 +44,25 @@ function request(requestId: string, platform: NodeJS.Platform): DesktopAppActiva
44 };44 };
45}45}
4646
47function startOkServer(target: ReturnType<typeof makeTarget>, userId: number | undefined) {
48 return startDesktopAppControlServer({
49 ...target,
50 userId,
51 handle: async (input) => ({
52 version: 1,
53 requestId: input.requestId,
54 ok: true,
55 projectId: ProjectId.make("project-1"),
56 threadId: ThreadId.make("thread-1"),
57 }),
58 cancel: () => undefined,
59 onReclaimError: () => undefined,
60 }).then((server) => {
61 openServers.push(server);
62 return server;
63 });
64}
65
47function exchange(address: string, payload: DesktopAppActivationRequest) {66function exchange(address: string, payload: DesktopAppActivationRequest) {
48 return new Promise<DesktopAppActivationResponse>((resolve, reject) => {67 return new Promise<DesktopAppActivationResponse>((resolve, reject) => {
49 const socket = NodeNet.createConnection(address);68 const socket = NodeNet.createConnection(address);
...@@ -84,6 +103,7 @@ describe("desktop app control server", () => {...@@ -84,6 +103,7 @@ describe("desktop app control server", () => {
84 };103 };
85 },104 },
86 cancel: () => undefined,105 cancel: () => undefined,
106 onReclaimError: () => undefined,
87 });107 });
88 openServers.push(server);108 openServers.push(server);
89109
...@@ -117,6 +137,7 @@ describe("desktop app control server", () => {...@@ -117,6 +137,7 @@ describe("desktop app control server", () => {
117 userId,137 userId,
118 handle: () => new Promise(() => undefined),138 handle: () => new Promise(() => undefined),
119 cancel: resolveCanceled,139 cancel: resolveCanceled,
140 onReclaimError: () => undefined,
120 });141 });
121 openServers.push(server);142 openServers.push(server);
122 const socket = NodeNet.createConnection(target.address);143 const socket = NodeNet.createConnection(target.address);
...@@ -137,4 +158,50 @@ describe("desktop app control server", () => {...@@ -137,4 +158,50 @@ describe("desktop app control server", () => {
137 });158 });
138 }),159 }),
139 );160 );
161
162 // Two desktop apps can share one state dir, such as nightly and a preview build.
163 it.effect("keeps a newer app's socket when an older app on the same state dir quits", () =>
164 Effect.gen(function* () {
165 const platform = yield* HostProcessPlatform;
166 const userId = yield* HostProcessUserId;
167 if (platform === "win32") return;
168 yield* Effect.promise(async () => {
169 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-takeover-test-"));
170 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
171 const older = await startOkServer(target, userId);
172 await startOkServer(target, userId);
173
174 await older.close();
175
176 await expect(
177 exchange(target.address, request("after-quit", platform)),
178 ).resolves.toMatchObject({ ok: true, requestId: "after-quit" });
179 await NodeFSP.rm(root, { recursive: true, force: true });
180 });
181 }),
182 );
183
184 it.effect("binds its address again after the socket file is removed", () =>
185 Effect.gen(function* () {
186 const platform = yield* HostProcessPlatform;
187 const userId = yield* HostProcessUserId;
188 if (platform === "win32") return;
189 yield* Effect.promise(async () => {
190 const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-reclaim-test-"));
191 const target = makeTarget(NodePath.join(root, "userdata"), platform, userId);
192 const server = await startOkServer(target, userId);
193
194 await NodeFSP.unlink(target.address);
195 await server.reclaim();
196
197 await expect(
198 exchange(target.address, request("reclaimed", platform)),
199 ).resolves.toMatchObject({
200 ok: true,
201 requestId: "reclaimed",
202 });
203 await NodeFSP.rm(root, { recursive: true, force: true });
204 });
205 }),
206 );
140});207});
apps/desktop/src/app/DesktopAppActivation.ts+139-39
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat uid and an atomic stale-socket unlink at the Node adapter boundary.1// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat, an atomic rename, and a directory watch at the Node adapter boundary.
2import * as NodeCrypto from "node:crypto";
3import * as NodeFS from "node:fs";
2import * as NodeFSP from "node:fs/promises";4import * as NodeFSP from "node:fs/promises";
3import * as NodeNet from "node:net";5import * as NodeNet from "node:net";
4import * as NodeOS from "node:os";6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
58
6import {9import {
7 DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,10 DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
...@@ -44,6 +47,8 @@ export class DesktopAppActivationStartError extends Schema.TaggedError<DesktopAp...@@ -44,6 +47,8 @@ export class DesktopAppActivationStartError extends Schema.TaggedError<DesktopAp
44}47}
4548
46interface RunningControlServer {49interface RunningControlServer {
50 /** Binds the address again if its socket file is gone. Directory changes run this too. */
51 readonly reclaim: () => Promise<void>;
47 readonly close: () => Promise<void>;52 readonly close: () => Promise<void>;
48}53}
4954
...@@ -70,12 +75,12 @@ function requestIdFromUnknown(value: unknown): string {...@@ -70,12 +75,12 @@ function requestIdFromUnknown(value: unknown): string {
70 return "invalid-request";75 return "invalid-request";
71}76}
7277
73async function prepareUnixSocket(input: {78/** Makes sure the socket directory is safe to use. Returns true when it had to create it. */
74 readonly address: string;79async function prepareUnixDirectory(input: {
75 readonly directory: string;80 readonly directory: string;
76 readonly userId: number | undefined;81 readonly userId: number | undefined;
77}): Promise<void> {82}): Promise<boolean> {
78 await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 });83 const created = await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 });
79 const stat = await NodeFSP.lstat(input.directory);84 const stat = await NodeFSP.lstat(input.directory);
80 if (!stat.isDirectory() || stat.isSymbolicLink()) {85 if (!stat.isDirectory() || stat.isSymbolicLink()) {
81 throw new Error(`${input.directory} is not a directory.`);86 throw new Error(`${input.directory} is not a directory.`);
...@@ -84,28 +89,43 @@ async function prepareUnixSocket(input: {...@@ -84,28 +89,43 @@ async function prepareUnixSocket(input: {
84 throw new Error(`${input.directory} is owned by another user.`);89 throw new Error(`${input.directory} is owned by another user.`);
85 }90 }
86 await NodeFSP.chmod(input.directory, 0o700);91 await NodeFSP.chmod(input.directory, 0o700);
87 await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => {92 return created !== undefined;
88 if (error.code !== "ENOENT") throw error;93}
89 });94
95async function inodeAt(path: string): Promise<number | null> {
96 return NodeFSP.lstat(path).then(
97 (stat) => stat.ino,
98 (error: NodeJS.ErrnoException) => {
99 if (error.code === "ENOENT") return null;
100 throw error;
101 },
102 );
103}
104
105function closeServer(server: NodeNet.Server): Promise<void> {
106 return new Promise((resolve) => server.close(() => resolve()));
90}107}
91108
109/**
110 * Serves `t3 app` requests on the local control address until `close`.
111 *
112 * Two desktop apps can share one state dir, for example nightly and a preview
113 * build. They share one socket path, so on Unix:
114 * - The newest app takes the path over.
115 * - `close` removes the socket file only while it is still this app's socket.
116 * - An app binds the path again when it is gone, for example after the app that
117 * took it over quits.
118 */
92export async function startDesktopAppControlServer(input: {119export async function startDesktopAppControlServer(input: {
93 readonly address: string;120 readonly address: string;
94 readonly directory: string | null;121 readonly directory: string | null;
95 readonly userId: number | undefined;122 readonly userId: number | undefined;
96 readonly handle: (request: DesktopAppActivationRequest) => Promise<DesktopAppActivationResponse>;123 readonly handle: (request: DesktopAppActivationRequest) => Promise<DesktopAppActivationResponse>;
97 readonly cancel: (requestId: string) => void;124 readonly cancel: (requestId: string) => void;
125 readonly onReclaimError: (error: unknown) => void;
98}): Promise<RunningControlServer> {126}): Promise<RunningControlServer> {
99 if (input.directory !== null) {
100 await prepareUnixSocket({
101 address: input.address,
102 directory: input.directory,
103 userId: input.userId,
104 });
105 }
106
107 const sockets = new Set<NodeNet.Socket>();127 const sockets = new Set<NodeNet.Socket>();
108 const server = NodeNet.createServer((socket) => {128 const handleConnection = (socket: NodeNet.Socket) => {
109 sockets.add(socket);129 sockets.add(socket);
110 socket.setEncoding("utf8");130 socket.setEncoding("utf8");
111 let buffer = "";131 let buffer = "";
...@@ -160,40 +180,116 @@ export async function startDesktopAppControlServer(input: {...@@ -160,40 +180,116 @@ export async function startDesktopAppControlServer(input: {
160 sockets.delete(socket);180 sockets.delete(socket);
161 if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId);181 if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId);
162 });182 });
163 });183 };
164184
165 await new Promise<void>((resolve, reject) => {185 const listen = (address: string) =>
166 const onError = (error: Error) => {186 new Promise<NodeNet.Server>((resolve, reject) => {
167 server.removeListener("listening", onListening);187 const server = NodeNet.createServer(handleConnection);
168 reject(error);188 const onError = (error: Error) => {
169 };189 server.removeListener("listening", onListening);
170 const onListening = () => {190 reject(error);
171 server.removeListener("error", onError);191 };
172 resolve();192 const onListening = () => {
173 };193 server.removeListener("error", onError);
174 server.once("error", onError);194 resolve(server);
175 server.once("listening", onListening);195 };
176 server.listen(input.address);196 server.once("error", onError);
177 });197 server.once("listening", onListening);
198 server.listen(address);
199 });
178200
179 try {201 // Closing a Unix socket server unlinks the path it was bound to, even when
180 if (input.directory !== null) {202 // another app's socket lives there now. Bind a staging path and move it onto
181 await NodeFSP.chmod(input.address, 0o600);203 // the address instead, so a later close only unlinks the staging path, which
204 // is already gone. `rename` takes the address over in one step. `link` claims
205 // it only while it is free, and fails with EEXIST otherwise.
206 const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => {
207 const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`);
208 const server = await listen(staging);
209 try {
210 await NodeFSP.chmod(staging, 0o600);
211 const inode = await inodeAt(staging);
212 if (mode === "take-over") {
213 await NodeFSP.rename(staging, input.address);
214 } else {
215 await NodeFSP.link(staging, input.address);
216 await NodeFSP.unlink(staging);
217 }
218 return { server, inode };
219 } catch (error) {
220 await closeServer(server);
221 throw error;
182 }222 }
183 } catch (error) {223 };
184 await new Promise<void>((resolve) => server.close(() => resolve()));224
185 throw error;225 let server: NodeNet.Server;
226 let inode: number | null = null;
227 if (input.directory === null) {
228 // Named pipes close with the app that owns them, so no other app can remove this one.
229 server = await listen(input.address);
230 } else {
231 await prepareUnixDirectory({ directory: input.directory, userId: input.userId });
232 ({ server, inode } = await bindUnix(input.directory, "take-over"));
186 }233 }
187234
188 let closed = false;235 let closed = false;
236 const reclaimOnce = async () => {
237 // Never replace a socket that exists, so two apps cannot trade the path back and forth.
238 if (closed || input.directory === null || (await inodeAt(input.address)) !== null) return;
239 if (await prepareUnixDirectory({ directory: input.directory, userId: input.userId })) {
240 // A watch follows the directory's inode, so a recreated directory needs a new one.
241 watchDirectory(input.directory);
242 }
243 const next = await bindUnix(input.directory, "claim-free").catch(
244 (error: NodeJS.ErrnoException) => {
245 // Another app bound the address first.
246 if (error.code === "EEXIST") return null;
247 throw error;
248 },
249 );
250 if (next === null) return;
251 const previous = server;
252 ({ server, inode } = next);
253 previous.close();
254 };
255 let pendingReclaim = Promise.resolve();
256 const reclaim = () => {
257 const run = pendingReclaim.then(reclaimOnce);
258 pendingReclaim = run.catch(() => undefined);
259 return run;
260 };
261 let watcher: NodeFS.FSWatcher | null = null;
262 const watchDirectory = (directory: string) => {
263 watcher?.close();
264 watcher = null;
265 try {
266 watcher = NodeFS.watch(directory, { persistent: false }, () => {
267 reclaim().catch(input.onReclaimError);
268 });
269 watcher.on("error", input.onReclaimError);
270 } catch (error) {
271 // The socket still works without a watcher. It only cannot recover after removal.
272 input.onReclaimError(error);
273 }
274 };
275 if (input.directory !== null) {
276 watchDirectory(input.directory);
277 // Catch a removal that happened before the watcher started.
278 reclaim().catch(input.onReclaimError);
279 }
280
189 return {281 return {
282 reclaim,
190 close: async () => {283 close: async () => {
191 if (closed) return;284 if (closed) return;
192 closed = true;285 closed = true;
286 // A running reclaim can replace the watcher, so close the watcher after it.
287 await pendingReclaim;
288 watcher?.close();
193 for (const socket of sockets) socket.destroy();289 for (const socket of sockets) socket.destroy();
194 await new Promise<void>((resolve) => server.close(() => resolve()));290 await closeServer(server);
195 server.removeAllListeners();291 server.removeAllListeners();
196 if (input.directory !== null) {292 if (inode !== null && (await inodeAt(input.address)) === inode) {
197 await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => {293 await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => {
198 if (error.code !== "ENOENT") throw error;294 if (error.code !== "ENOENT") throw error;
199 });295 });
...@@ -258,6 +354,10 @@ export const make = Effect.gen(function* () {...@@ -258,6 +354,10 @@ export const make = Effect.gen(function* () {
258 userId,354 userId,
259 handle: (request) => broker.request(request),355 handle: (request) => broker.request(request),
260 cancel: (requestId) => broker.cancel(requestId),356 cancel: (requestId) => broker.cancel(requestId),
357 onReclaimError: (cause) =>
358 void runPromise(
359 logWarning("failed to restore the desktop app control socket", { cause }),
360 ),
261 }),361 }),
262 catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }),362 catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }),
263 }),363 }),