Skip to content
Merged
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 .macroscope/check-run-agents/effect-service-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ Review changed TypeScript for the conventions below. They apply when a pull requ
- Define service failures with `Schema.TaggedError` and structured attributes: operation or stage, resource path or entity identifier, normalized category or status. Derive `message` from those attributes only. Never derive it from `cause`, `cause.message`, or a stringified defect, and do not add a `detail` field that copies `cause.message`.
- When wrapping a real failure, keep the immediate underlying error as `cause` so the chain and stack survive. Make `cause` required if every construction wraps a failure. Pure validation or domain errors created without an underlying failure need no cause.
- Keep attributes and log annotations safe and bounded: no raw wire payloads, command arguments or output, signed URLs, credentials, query strings, or arbitrary defect text. Preserve the exact value only as `cause`; expose normalized categories, lengths, counts, and safe URL protocol or hostname where useful.
The documented exception is `DeviceBootError.diagnostics`: device hub launch commands and failure text are intentionally exposed to authorized device users and agents, capped at 2,000 characters and included in the error message. Do not apply this exception to other service errors.
- At a translation boundary, pass through an already structured domain error when it is part of the target error channel; wrap only unknown or lower-level failures. Map failures where the context is known instead of wrapping a whole multi-step pipeline in one generic error.
- Do not encode the same distinction twice with both a specific error tag and a single-value `operation`, `reason`, `kind`, or `phase` literal. Split into separate error classes when a discriminator drives caller control flow or the user-facing message; a discriminator used only for diagnostics may stay a field. Caller-visible messages exposed through HTTP, RPC, persisted state, or UI are behavior and must survive a structural refactor.
- Do not add a helper whose only behavior is `(...args) => new SomeError({ ...args })`. Construct the error at the failure boundary. Keep a mapper only when it performs real normalization, passes through domain errors, or adds reusable context; when such a mapper belongs to the target error type, prefer a static factory on that class.
Expand Down
112 changes: 107 additions & 5 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "@effect/vitest";
import {
DEFAULT_SERVER_SETTINGS,
DeviceBootError,
DeviceId,
DeviceOperationError,
LOCAL_DEVICE_HOST_ID,
Expand Down Expand Up @@ -37,6 +38,8 @@ import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime";
import * as DeviceService from "./DeviceService.ts";

const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));
const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBootError));
const encodeBootError = Schema.encodeSync(Schema.fromJsonString(DeviceBootError));

const baseState: DeviceServiceState = {
hosts: [],
Expand Down Expand Up @@ -77,7 +80,7 @@ describe("DeviceService.stateStream", () => {

const fixture = Effect.fn("fixture")(function* (
onBoot: Effect.Effect<void> = Effect.void,
bootError?: string,
bootError?: string | { error?: string; errors?: Array<{ message: string; error?: string }> },
failListAfterShutdown = false,
runtimeFailure?:
| NodeRuntimeUnavailableError
Expand All @@ -92,6 +95,7 @@ const fixture = Effect.fn("fixture")(function* (
const agentStops: string[] = [];
const requests: string[] = [];
const devices: Array<Omit<DeviceSummary, "hostId">> = [];
const discoveryErrors = yield* Ref.make<Array<{ message: string; error?: string }>>([]);
const encoderResult = yield* Ref.make({ code: 0, stdout: " V....D libx264", stderr: "" });
let booted = false;
let shutDown = false;
Expand Down Expand Up @@ -212,7 +216,12 @@ const fixture = Effect.fn("fixture")(function* (
return HttpClientResponse.fromWeb(
request,
Response.json(
bootError ? { ok: false, error: bootError } : { ok: true, serial: "emulator-5554" },
bootError
? {
ok: false,
...(typeof bootError === "string" ? { error: bootError } : bootError),
}
: { ok: true, serial: "emulator-5554" },
),
);
}
Expand All @@ -222,6 +231,7 @@ const fixture = Effect.fn("fixture")(function* (
return HttpClientResponse.fromWeb(
request,
Response.json({
errors: yield* Ref.get(discoveryErrors),
simulators: devices.filter((device) => device.platform === "ios"),
emulators: [
...devices.filter((device) => device.platform === "android"),
Expand All @@ -244,7 +254,17 @@ const fixture = Effect.fn("fixture")(function* (
),
),
);
return { service, starts, agentStarts, agentStops, requests, settings, devices, encoderResult };
return {
service,
starts,
agentStarts,
agentStops,
requests,
settings,
devices,
encoderResult,
discoveryErrors,
};
});

describe("device setup consent", () => {
Expand Down Expand Up @@ -506,6 +526,60 @@ describe("device discovery after server restart", () => {
);
});

const missingPlistMessage =
"[apple-utils] Failed to read CoreSimulator plist at /Users/test/Library/Developer/CoreSimulator/Devices/deleted-simulator/device.plist:";

it.effect.each([
{
message: missingPlistMessage,
error: "Error: ENOENT: no such file or directory, open 'device.plist'",
ignored: true,
},
{
message: missingPlistMessage,
error: "Error: EACCES: permission denied, open 'device.plist'",
ignored: false,
},
{ message: missingPlistMessage, error: undefined, ignored: false },
{
message: "[apple-utils] Failed to parse CoreSimulator device.plist:",
error: "Invalid XML",
ignored: false,
},
{
message: "[apple-utils] Failed to list devices:",
error: "Error: ENOENT: simctl missing",
ignored: false,
},
])("handles discovery diagnostic: $error", ({ message, error, ignored }) =>
Effect.gen(function* () {
const { service, discoveryErrors } = yield* fixture();
yield* Ref.set(discoveryErrors, [{ message, ...(error ? { error } : {}) }]);
const state = yield* service.configure({ enabled: true });
expect(state.hostStatus).toBe("ready");
expect(state.devices.map((device) => device.id)).toEqual(["Pixel_API_35"]);
expect(state.hostStatusDetail).toBe(ignored ? undefined : message);
expect(state.hostStatuses.local?.detail).toBe(ignored ? undefined : message);
}).pipe(Effect.scoped),
);

it.effect("keeps other discovery warnings and clears them when only a deleted plist remains", () =>
Effect.gen(function* () {
const { service, discoveryErrors } = yield* fixture();
const missing = {
message: missingPlistMessage,
error: "Error: ENOENT: no such file or directory",
};
yield* Ref.set(discoveryErrors, [missing, { message: "Android discovery failed." }]);
const initial = yield* service.configure({ enabled: true });
expect(initial.hostStatusDetail).toBe("Android discovery failed.");
yield* Ref.set(discoveryErrors, [missing]);
const refreshed = yield* service.list;
expect(refreshed.hostStatusDetail).toBeUndefined();
expect(refreshed.hostStatuses.local?.detail).toBeUndefined();
}).pipe(Effect.scoped),
);

it.effect.each([
{
diagnostic: "Insufficient disk space at /private/user/path",
Expand All @@ -518,7 +592,8 @@ it.effect.each([
message: "did not become ready in time",
},
{
diagnostic: "Unexpected failure: secret-token",
diagnostic:
"The emulator process exited with code 1.\nTry manually: emulator -avd Pixel_API_35",
reason: "launch_failed",
message: "could not start",
},
Expand All @@ -535,7 +610,34 @@ it.effect.each([
.pipe(Effect.flip);
expect(error._tag).toBe("DeviceBootError");
expect(error.message).toContain(message);
expect(error.message).not.toContain(diagnostic);
expect(error.message).toContain(diagnostic);
expect((yield* service.state).bootingDevices).toEqual([]);
}).pipe(Effect.scoped),
);

it.effect("includes nested hub boot diagnostics and bounds them to 2,000 characters", () =>
Effect.gen(function* () {
const diagnostic = "Error: emulator binary missing\n" + "diagnostic output\n".repeat(200);
const { service } = yield* fixture(Effect.void, {
error: "Failed to spawn emulator",
errors: [{ message: "Failed to launch emulator binary", error: diagnostic }],
});
yield* service.configure({ enabled: true });
const error = yield* service
.open({
threadId: ThreadId.make("nested-boot-failure"),
deviceId: "Pixel_API_35",
platform: "android",
})
.pipe(Effect.flip);
expect(error._tag).toBe("DeviceBootError");
if (error._tag !== "DeviceBootError") return;
const expected = `Failed to spawn emulator\n\nFailed to launch emulator binary\n\n${diagnostic}`;
expect(error.diagnostics).toBe(`${expected.slice(0, 1_999)}…`);
expect(error.diagnostics).toHaveLength(2_000);
expect(error.message).toContain(error.diagnostics);
const roundTrip = decodeBootError(encodeBootError(error));
expect(roundTrip.message).toBe(error.message);
expect((yield* service.state).bootingDevices).toEqual([]);
}).pipe(Effect.scoped),
);
Expand Down
35 changes: 31 additions & 4 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,18 @@ const HubDevice = Schema.Struct({
const HubDeviceList = Schema.Struct({
simulators: Schema.Array(HubDevice),
emulators: Schema.Array(HubDevice),
errors: Schema.optional(Schema.Array(Schema.Struct({ message: Schema.String }))),
errors: Schema.optional(
Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })),
),
});
const HubActionResult = Schema.Struct({
ok: Schema.Boolean,
id: Schema.optional(Schema.String),
serial: Schema.optional(Schema.String),
error: Schema.optional(Schema.String),
errors: Schema.optional(
Schema.Array(Schema.Struct({ message: Schema.String, error: Schema.optional(Schema.String) })),
),
});

export interface DeviceScreenshot {
Expand Down Expand Up @@ -439,7 +444,16 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
}
}
}
return { devices, detail: list.errors?.map((error) => error.message).join("\n") || undefined };
const warnings = list.errors?.filter(
(error) =>
// CoreSimulator can retain deleted devices; their optional usage timestamps are absent.
!(
error.message.startsWith("[apple-utils] Failed to read CoreSimulator plist at ") &&
/\/CoreSimulator\/Devices\/[^/]+\/device\.plist:$/.test(error.message) &&
/(?:^Error:\s*ENOENT:|\bcode:\s*['"]ENOENT['"])/.test(error.error ?? "")
),
);
return { devices, detail: warnings?.map((error) => error.message).join("\n") || undefined };
});

const refresh = Effect.fn("DeviceService.refresh")(function* (ready: DeviceReadiness) {
Expand Down Expand Up @@ -630,16 +644,29 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function*
Effect.flatMap((request) => hubJson(request, HubActionResult, "boot", BOOT_TIMEOUT)),
);
if (!result.ok) {
// Keep the hub's launch command and nested failure available to agents for recovery.
const diagnostics = [
result.error,
...(result.errors ?? []).flatMap((error) => [error.message, error.error]),
]
.filter((text) => text?.trim())
.join("\n\n");
return yield* new DeviceBootError({
hostId: ready.hostId,
deviceId: device.id,
reason: /insufficient.*(?:disk|space)|not enough.*(?:disk|space)|no space left/i.test(
result.error ?? "",
diagnostics,
)
? "disk_space"
: /timed? out|timeout/i.test(result.error ?? "")
: /timed? out|timeout/i.test(diagnostics)
? "timeout"
: "launch_failed",
...(diagnostics
? {
diagnostics:
diagnostics.length > 2_000 ? `${diagnostics.slice(0, 1_999)}…` : diagnostics,
}
: {}),
cause: result,
});
}
Expand Down
68 changes: 67 additions & 1 deletion apps/server/src/device/LocalDeviceHost.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,73 @@ it.effect(
Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})),
);
expect(missing.XDG_RUNTIME_DIR).toBeUndefined();
}).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
}).pipe(
Effect.scoped,
Effect.provide(Layer.provideMerge(ProcessRunner.layer, NodeServices.layer)),
),
);

it.effect.each([
{
name: "old SDK",
front: "emulated webcam<N> none",
back: "emulated virtualscene none",
code: 0,
extra: undefined,
expected: "-camera-front emulated -camera-back emulated",
},
{
name: "imagefile-capable SDK",
front: "emulated imagefile:<path> none",
back: "emulated imagefile:<path> none",
code: 0,
extra: undefined,
expected: undefined,
},
{
name: "user overrides",
front: "emulated none",
back: "emulated none",
code: 0,
extra: "-memory 4096 -camera-front none",
expected: "-camera-front emulated -camera-back emulated -memory 4096 -camera-front none",
},
{
name: "different camera capabilities",
front: "emulated none",
back: "emulated imagefile:<path> none",
code: 0,
extra: undefined,
expected: "-camera-front emulated",
},
{
name: "failed help probe",
front: "emulated none",
back: "emulated none",
code: 1,
extra: undefined,
expected: undefined,
},
])("configures supported emulator cameras for $name", ({ front, back, code, extra, expected }) =>
Effect.gen(function* () {
const result = yield* LocalDeviceHost.__testing
.hubEnvironment({ ANDROID_HOME: "/sdk", EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS: extra })
.pipe(
Effect.provideService(HostProcess.Platform, "darwin"),
Effect.provideService(HostProcess.UserId, 1000),
Effect.provideService(ProcessRunner.ProcessRunner, {
run: (input) => {
expect(input.command).toBe("/sdk/emulator/emulator");
expect(input.args[0]).toMatch(/^-help-camera-(front|back)$/);
return Effect.succeed(
processOutput(code, input.args[0] === "-help-camera-front" ? front : back),
);
},
}),
Effect.provide(NodeServices.layer),
);
expect(result.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS).toBe(expected);
}),
);

it.live("limits hub discovery to Android unless the host is a Mac", () =>
Expand Down
36 changes: 36 additions & 0 deletions apps/server/src/device/LocalDeviceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,40 @@ const hubEnvironment = Effect.fn("LocalDeviceHost.hubEnvironment")(function* (
const env: NodeJS.ProcessEnv = { ...environment, FORCE_COLOR: "0", NO_COLOR: "1" };
const platform = yield* HostProcess.Platform;
const uid = yield* HostProcess.UserId;
if (env.ANDROID_HOME) {
const path = yield* Path.Path;
const runner = yield* ProcessRunner.ProcessRunner;
const emulator = path.join(
env.ANDROID_HOME,
"emulator",
platform === "win32" ? "emulator.exe" : "emulator",
);
const cameraArgs = yield* Effect.forEach(["front", "back"], (facing) =>
runner
.run({
command: emulator,
args: [`-help-camera-${facing}`],
env,
timeout: Duration.seconds(5),
})
.pipe(
Effect.map((result) => {
const help = `${result.stdout}\n${result.stderr}`;
// The hub wires imagefile cameras unconditionally; older SDK emulators reject that mode.
return result.code === 0 && /\bemulated\b/.test(help) && !/\bimagefile\b/.test(help)
? `-camera-${facing} emulated`
: "";
}),
Effect.orElseSucceed(() => ""),
),
);
const overrides = cameraArgs.filter(Boolean).join(" ");
if (overrides) {
env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS = [overrides, env.EXPO_DEVICE_HUB_EMULATOR_EXTRA_ARGS]
.filter(Boolean)
.join(" ");
}
}
if (platform === "linux" && env.XDG_RUNTIME_DIR === undefined && uid !== undefined) {
// SSH sessions may omit the directory where the emulator publishes its gRPC token.
const runtimeDir = `/run/user/${uid}`;
Expand Down Expand Up @@ -409,6 +443,8 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () {
stderr: "pipe",
env: yield* hubEnvironment(hostEnvironment).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
Effect.provideService(ProcessRunner.ProcessRunner, runner),
Effect.provideService(HostProcess.Platform, hostPlatform),
),
},
Expand Down
Loading
Loading