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
4444 };
4545}
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
4766function exchange(address: string, payload: DesktopAppActivationRequest) {
4867 return new Promise<DesktopAppActivationResponse>((resolve, reject) => {
4968 const socket = NodeNet.createConnection(address);
......@@ -84,6 +103,7 @@ describe("desktop app control server", () => {
84103 };
85104 },
86105 cancel: () => undefined,
106 onReclaimError: () => undefined,
87107 });
88108 openServers.push(server);
89109
......@@ -117,6 +137,7 @@ describe("desktop app control server", () => {
117137 userId,
118138 handle: () => new Promise(() => undefined),
119139 cancel: resolveCanceled,
140 onReclaimError: () => undefined,
120141 });
121142 openServers.push(server);
122143 const socket = NodeNet.createConnection(target.address);
......@@ -137,4 +158,50 @@ describe("desktop app control server", () => {
137158 });
138159 }),
139160 );
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 );
140207});
apps/desktop/src/app/DesktopAppActivation.ts+139-39
......@@ -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";
24import * as NodeFSP from "node:fs/promises";
35import * as NodeNet from "node:net";
46import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
58
69import {
710 DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION,
......@@ -44,6 +47,8 @@ export class DesktopAppActivationStartError extends Schema.TaggedError<DesktopAp
4447}
4548
4649interface RunningControlServer {
50 /** Binds the address again if its socket file is gone. Directory changes run this too. */
51 readonly reclaim: () => Promise<void>;
4752 readonly close: () => Promise<void>;
4853}
4954
......@@ -70,12 +75,12 @@ function requestIdFromUnknown(value: unknown): string {
7075 return "invalid-request";
7176}
7277
73async function prepareUnixSocket(input: {
74 readonly address: string;
78/** Makes sure the socket directory is safe to use. Returns true when it had to create it. */
79async function prepareUnixDirectory(input: {
7580 readonly directory: string;
7681 readonly userId: number | undefined;
77}): Promise<void> {
78 await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 });
82}): Promise<boolean> {
83 const created = await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 });
7984 const stat = await NodeFSP.lstat(input.directory);
8085 if (!stat.isDirectory() || stat.isSymbolicLink()) {
8186 throw new Error(`${input.directory} is not a directory.`);
......@@ -84,28 +89,43 @@ async function prepareUnixSocket(input: {
8489 throw new Error(`${input.directory} is owned by another user.`);
8590 }
8691 await NodeFSP.chmod(input.directory, 0o700);
87 await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => {
88 if (error.code !== "ENOENT") throw error;
89 });
92 return created !== undefined;
93}
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()));
90107}
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 */
92119export async function startDesktopAppControlServer(input: {
93120 readonly address: string;
94121 readonly directory: string | null;
95122 readonly userId: number | undefined;
96123 readonly handle: (request: DesktopAppActivationRequest) => Promise<DesktopAppActivationResponse>;
97124 readonly cancel: (requestId: string) => void;
125 readonly onReclaimError: (error: unknown) => void;
98126}): 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
107127 const sockets = new Set<NodeNet.Socket>();
108 const server = NodeNet.createServer((socket) => {
128 const handleConnection = (socket: NodeNet.Socket) => {
109129 sockets.add(socket);
110130 socket.setEncoding("utf8");
111131 let buffer = "";
......@@ -160,40 +180,116 @@ export async function startDesktopAppControlServer(input: {
160180 sockets.delete(socket);
161181 if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId);
162182 });
163 });
183 };
164184
165 await new Promise<void>((resolve, reject) => {
166 const onError = (error: Error) => {
167 server.removeListener("listening", onListening);
168 reject(error);
169 };
170 const onListening = () => {
171 server.removeListener("error", onError);
172 resolve();
173 };
174 server.once("error", onError);
175 server.once("listening", onListening);
176 server.listen(input.address);
177 });
185 const listen = (address: string) =>
186 new Promise<NodeNet.Server>((resolve, reject) => {
187 const server = NodeNet.createServer(handleConnection);
188 const onError = (error: Error) => {
189 server.removeListener("listening", onListening);
190 reject(error);
191 };
192 const onListening = () => {
193 server.removeListener("error", onError);
194 resolve(server);
195 };
196 server.once("error", onError);
197 server.once("listening", onListening);
198 server.listen(address);
199 });
178200
179 try {
180 if (input.directory !== null) {
181 await NodeFSP.chmod(input.address, 0o600);
201 // Closing a Unix socket server unlinks the path it was bound to, even when
202 // another app's socket lives there now. Bind a staging path and move it onto
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;
182222 }
183 } catch (error) {
184 await new Promise<void>((resolve) => server.close(() => resolve()));
185 throw error;
223 };
224
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"));
186233 }
187234
188235 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
189281 return {
282 reclaim,
190283 close: async () => {
191284 if (closed) return;
192285 closed = true;
286 // A running reclaim can replace the watcher, so close the watcher after it.
287 await pendingReclaim;
288 watcher?.close();
193289 for (const socket of sockets) socket.destroy();
194 await new Promise<void>((resolve) => server.close(() => resolve()));
290 await closeServer(server);
195291 server.removeAllListeners();
196 if (input.directory !== null) {
292 if (inode !== null && (await inodeAt(input.address)) === inode) {
197293 await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => {
198294 if (error.code !== "ENOENT") throw error;
199295 });
......@@ -258,6 +354,10 @@ export const make = Effect.gen(function* () {
258354 userId,
259355 handle: (request) => broker.request(request),
260356 cancel: (requestId) => broker.cancel(requestId),
357 onReclaimError: (cause) =>
358 void runPromise(
359 logWarning("failed to restore the desktop app control socket", { cause }),
360 ),
261361 }),
262362 catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }),
263363 }),