authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-04 22:41:04-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-04 22:41:04-03:00
logf729e0ebfe7578860585a77ddde8f008e30c2351
tree410cde85ddc5a8a866d244e502b734d4bb1b2bec
parentf4153a75a4b04e14bd45d1b578af4d1d63cc84e5
signature Signed by PGP key B5690EEEBB952194

fix(server): recover Linux runtime directory for device hub (#12402)


2 files changed, 83 insertions(+), 8 deletions(-)

apps/server/src/device/LocalDeviceHost.test.ts+50
...@@ -4,9 +4,11 @@ import {...@@ -4,9 +4,11 @@ import {
4 HostProcessEnvironment,4 HostProcessEnvironment,
5 HostProcessPlatform,5 HostProcessPlatform,
6 HostProcessIsExecutable,6 HostProcessIsExecutable,
7 HostProcessUserId,
7} from "@t3tools/shared/hostProcess";8} from "@t3tools/shared/hostProcess";
8import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
10import * as Path from "effect/Path";12import * as Path from "effect/Path";
11import * as FileSystem from "effect/FileSystem";13import * as FileSystem from "effect/FileSystem";
1214
...@@ -134,6 +136,54 @@ it.effect("puts detected Android tools on the helper PATH without losing existin...@@ -134,6 +136,54 @@ it.effect("puts detected Android tools on the helper PATH without losing existin
134 }).pipe(Effect.provide(NodePath.layer)),136 }).pipe(Effect.provide(NodePath.layer)),
135);137);
136138
139it.effect(
140 "recovers an owned Linux runtime directory without overriding explicit environments",
141 () =>
142 Effect.gen(function* () {
143 const fs = yield* FileSystem.FileSystem;
144 const directory = yield* fs.makeTempDirectoryScoped();
145 const stat = yield* fs.stat(directory);
146 for (const [platform, uid, runtimeDir, type, owner, expected] of [
147 ["linux", 1000, undefined, "Directory", 1000, "/run/user/1000"],
148 ["linux", 1000, "/custom/runtime", "Directory", 1000, "/custom/runtime"],
149 ["linux", 1000, "", "Directory", 1000, ""],
150 ["linux", 1000, undefined, "Directory", 1001, undefined],
151 ["linux", 1000, undefined, "File", 1000, undefined],
152 ["linux", undefined, undefined, "Directory", 1000, undefined],
153 ["darwin", 1000, undefined, "Directory", 1000, undefined],
154 ["win32", 1000, undefined, "Directory", 1000, undefined],
155 ] as const) {
156 const environment = { PATH: "/usr/bin", XDG_RUNTIME_DIR: runtimeDir };
157 const result = yield* LocalDeviceHost.__testing.hubEnvironment(environment).pipe(
158 Effect.provideService(HostProcessPlatform, platform),
159 Effect.provideService(HostProcessUserId, uid),
160 Effect.provideService(
161 FileSystem.FileSystem,
162 FileSystem.makeNoop({
163 stat: (path) => {
164 expect(path).toBe("/run/user/1000");
165 return Effect.succeed({ ...stat, type, uid: Option.some(owner) });
166 },
167 }),
168 ),
169 );
170 expect(result.XDG_RUNTIME_DIR).toBe(expected);
171 expect(result.PATH).toBe("/usr/bin");
172 expect(result.FORCE_COLOR).toBe("0");
173 expect(result.NO_COLOR).toBe("1");
174 expect(environment.XDG_RUNTIME_DIR).toBe(runtimeDir);
175 }
176 const missing = yield* LocalDeviceHost.__testing
177 .hubEnvironment({})
178 .pipe(
179 Effect.provideService(HostProcessPlatform, "linux"),
180 Effect.provideService(HostProcessUserId, 1000),
181 Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
182 );
183 expect(missing.XDG_RUNTIME_DIR).toBeUndefined();
184 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
185);
186
137it.effect(187it.effect(
138 "constructs and inspects an unconfigured host without installing or starting helpers",188 "constructs and inspects an unconfigured host without installing or starting helpers",
139 () =>189 () =>
apps/server/src/device/LocalDeviceHost.ts+33-8
...@@ -19,7 +19,11 @@ import {...@@ -19,7 +19,11 @@ import {
19 LOCAL_DEVICE_HOST_ID,19 LOCAL_DEVICE_HOST_ID,
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { waitForHttpReady } from "@t3tools/shared/httpReadiness";21import { waitForHttpReady } from "@t3tools/shared/httpReadiness";
22import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";22import {
23 HostProcessEnvironment,
24 HostProcessPlatform,
25 HostProcessUserId,
26} from "@t3tools/shared/hostProcess";
23import {27import {
24 resolveNodeExecutable,28 resolveNodeExecutable,
25 type NodeRuntimeUnavailableError,29 type NodeRuntimeUnavailableError,
...@@ -32,6 +36,7 @@ import * as Effect from "effect/Effect";...@@ -32,6 +36,7 @@ import * as Effect from "effect/Effect";
32import * as Exit from "effect/Exit";36import * as Exit from "effect/Exit";
33import * as FileSystem from "effect/FileSystem";37import * as FileSystem from "effect/FileSystem";
34import * as Layer from "effect/Layer";38import * as Layer from "effect/Layer";
39import * as Option from "effect/Option";
35import * as Path from "effect/Path";40import * as Path from "effect/Path";
36import * as Option from "effect/Option";41import * as Option from "effect/Option";
37import * as Ref from "effect/Ref";42import * as Ref from "effect/Ref";
...@@ -199,6 +204,28 @@ const deviceHostEnvironment = (...@@ -199,6 +204,28 @@ const deviceHostEnvironment = (
199 : environment;204 : environment;
200};205};
201206
207const hubEnvironment = Effect.fn("LocalDeviceHost.hubEnvironment")(function* (
208 environment: NodeJS.ProcessEnv,
209) {
210 const env: NodeJS.ProcessEnv = { ...environment, FORCE_COLOR: "0", NO_COLOR: "1" };
211 const platform = yield* HostProcessPlatform;
212 const uid = yield* HostProcessUserId;
213 if (platform === "linux" && env.XDG_RUNTIME_DIR === undefined && uid !== undefined) {
214 // SSH sessions may omit the directory where the emulator publishes its gRPC token.
215 const runtimeDir = `/run/user/${uid}`;
216 const fs = yield* FileSystem.FileSystem;
217 const stat = yield* fs.stat(runtimeDir).pipe(Effect.option);
218 if (
219 stat._tag === "Some" &&
220 stat.value.type === "Directory" &&
221 Option.contains(stat.value.uid, uid)
222 ) {
223 env.XDG_RUNTIME_DIR = runtimeDir;
224 }
225 }
226 return env;
227});
228
202export const make = Effect.fn("LocalDeviceHost.make")(function* () {229export const make = Effect.fn("LocalDeviceHost.make")(function* () {
203 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;230 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
204 const config = yield* ServerConfig.ServerConfig;231 const config = yield* ServerConfig.ServerConfig;
...@@ -267,12 +294,6 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -267,12 +294,6 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
267 };294 };
268 });295 });
269296
270 const hubEnvironment = (): NodeJS.ProcessEnv => ({
271 ...hostEnvironment,
272 FORCE_COLOR: "0",
273 NO_COLOR: "1",
274 });
275
276 const stopHub = (hub: HubProcess | undefined) =>297 const stopHub = (hub: HubProcess | undefined) =>
277 hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void;298 hub ? Scope.close(hub.scope, Exit.void).pipe(Effect.ignore) : Effect.void;
278299
...@@ -376,7 +397,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {...@@ -376,7 +397,10 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
376 shell: false,397 shell: false,
377 stdout: "pipe",398 stdout: "pipe",
378 stderr: "pipe",399 stderr: "pipe",
379 env: hubEnvironment(),400 env: yield* hubEnvironment(hostEnvironment).pipe(
401 Effect.provideService(FileSystem.FileSystem, fs),
402 Effect.provideService(HostProcessPlatform, hostPlatform),
403 ),
380 },404 },
381 ),405 ),
382 )406 )
...@@ -766,4 +790,5 @@ export const __testing = {...@@ -766,4 +790,5 @@ export const __testing = {
766 androidSdk,790 androidSdk,
767 platformReason,791 platformReason,
768 deviceHostEnvironment,792 deviceHostEnvironment,
793 hubEnvironment,
769};794};