| author | |
| committer | |
| log | c9fa1367caff4cd6595386814e1f8bb950d3d3ef |
| tree | d41c9eaae68bea87ad147bc40c9e5c3ca37d9c9f |
| parent | 42c6623d492850c747b7ba06c1d0f1e1559d0ad5 |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 120 insertions(+), 61 deletions(-)
apps/server/src/preview/DesktopBrowserChannel.test.ts+55-54| ... | @@ -1,58 +1,59 @@ | ... | @@ -1,58 +1,59 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - The channel reads real file descriptors. | 1 | // @effect-diagnostics nodeBuiltinImport:off - Exercises the real inherited descriptor lifecycle in a subprocess. |
| 2 | import * as NodeFS from "node:fs"; | 2 | import * as NodeChildProcess from "node:child_process"; |
| 3 | import * as NodeOS from "node:os"; | 3 | import * as NodeURL from "node:url"; |
| 4 | import * as NodePath from "node:path"; | ||
| 5 | 4 | ||
| 6 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 5 | import { expect, it } from "vite-plus/test"; |
| 7 | import { expect, it } from "@effect/vitest"; | ||
| 8 | import * as Context from "effect/Context"; | ||
| 9 | import * as Effect from "effect/Effect"; | ||
| 10 | import * as Exit from "effect/Exit"; | ||
| 11 | import * as Layer from "effect/Layer"; | ||
| 12 | 6 | ||
| 13 | import * as ServerConfig from "../config.ts"; | 7 | it("receives desktop messages and exits while the parent keeps both input pipes open", async () => { |
| 14 | import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; | 8 | const child = NodeChildProcess.spawn( |
| 15 | 9 | process.execPath, | |
| 16 | const key = { threadId: "thread-1", tabId: "tab-1" }; | 10 | [NodeURL.fileURLToPath(new URL("./testing/DesktopPipeLifecycle.fixture.ts", import.meta.url))], |
| 17 | 11 | { stdio: ["ignore", "pipe", "pipe", "pipe", "pipe", "pipe", "pipe"] }, | |
| 18 | /** The channel over two files: what the desktop sent, and where commands go. */ | ||
| 19 | const channelOver = (events: ReadonlyArray<Record<string, unknown>>) => | ||
| 20 | Effect.gen(function* () { | ||
| 21 | const directory = yield* Effect.acquireRelease( | ||
| 22 | Effect.sync(() => | ||
| 23 | NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-browser-channel-")), | ||
| 24 | ), | ||
| 25 | (directory) => Effect.sync(() => NodeFS.rmSync(directory, { recursive: true, force: true })), | ||
| 26 | ); | ||
| 27 | const inbound = NodePath.join(directory, "events.ndjson"); | ||
| 28 | NodeFS.writeFileSync(inbound, events.map((event) => `${JSON.stringify(event)}\n`).join("")); | ||
| 29 | const control = yield* Effect.acquireRelease( | ||
| 30 | Effect.sync(() => NodeFS.openSync(NodePath.join(directory, "commands.ndjson"), "w")), | ||
| 31 | (fd) => Effect.sync(() => NodeFS.closeSync(fd)), | ||
| 32 | ); | ||
| 33 | const base = yield* ServerConfig.ServerConfig.pipe( | ||
| 34 | Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-desktop-browser-" })), | ||
| 35 | ); | ||
| 36 | const config = Layer.succeed(ServerConfig.ServerConfig, { | ||
| 37 | ...base, | ||
| 38 | desktopBrowserFd: NodeFS.openSync(inbound, "r"), | ||
| 39 | desktopBrowserControlFd: control, | ||
| 40 | }); | ||
| 41 | // Built in the caller's scope, so the channel outlives this setup. | ||
| 42 | const context = yield* Layer.build(DesktopBrowserChannel.layer.pipe(Layer.provide(config))); | ||
| 43 | return Context.get(context, DesktopBrowserChannel.DesktopBrowserChannel); | ||
| 44 | }); | ||
| 45 | |||
| 46 | it.layer(NodeServices.layer)("DesktopBrowserChannel", (it) => { | ||
| 47 | it.effect("refuses an endpoint for a tab that is no longer attached", () => | ||
| 48 | Effect.gen(function* () { | ||
| 49 | const channel = yield* channelOver([ | ||
| 50 | { type: "attached", ...key }, | ||
| 51 | { type: "detached", ...key }, | ||
| 52 | ]); | ||
| 53 | // Whether or not the reader has reached these lines yet, the tab is not attached. | ||
| 54 | const exit = yield* Effect.exit(Effect.scoped(channel.endpoint(key))); | ||
| 55 | expect(Exit.isFailure(exit)).toBe(true); | ||
| 56 | }).pipe(Effect.scoped), | ||
| 57 | ); | 12 | ); |
| 13 | // A failed exit must not leave a test process running. This never fires on success. | ||
| 14 | // @effect-diagnostics-next-line globalTimers:off -- Bounds the native subprocess on failure; success waits for process exit. | ||
| 15 | const watchdog = setTimeout(() => child.kill("SIGKILL"), 8_000); | ||
| 16 | let output = ""; | ||
| 17 | let errors = ""; | ||
| 18 | let verified = false; | ||
| 19 | child.stderr?.on("data", (chunk: Buffer) => { | ||
| 20 | errors += chunk.toString(); | ||
| 21 | }); | ||
| 22 | const write = (fd: number, value: Record<string, unknown>) => { | ||
| 23 | const stream = child.stdio[fd]; | ||
| 24 | if (!stream || !("write" in stream)) throw new Error(`Missing pipe ${fd}`); | ||
| 25 | stream.write(`${JSON.stringify(value)}\n`); | ||
| 26 | }; | ||
| 27 | const key = { threadId: "thread-1", tabId: "tab-1" }; | ||
| 28 | child.stdout?.on("data", (chunk: Buffer) => { | ||
| 29 | output += chunk.toString(); | ||
| 30 | let end: number; | ||
| 31 | while ((end = output.indexOf("\n")) >= 0) { | ||
| 32 | const line = output.slice(0, end); | ||
| 33 | output = output.slice(end + 1); | ||
| 34 | if (line === "ready") { | ||
| 35 | write(3, { type: "attached", ...key }); | ||
| 36 | write(5, { version: 1, type: "desktopTelemetryHello", electronPid: process.pid }); | ||
| 37 | } else if (line === "attached") { | ||
| 38 | write(3, { type: "detached", ...key }); | ||
| 39 | } else if (line === "verified") { | ||
| 40 | verified = true; | ||
| 41 | } | ||
| 42 | } | ||
| 43 | }); | ||
| 44 | try { | ||
| 45 | const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( | ||
| 46 | (resolve, reject) => { | ||
| 47 | child.once("error", reject); | ||
| 48 | child.once("exit", (code, signal) => resolve({ code, signal })); | ||
| 49 | }, | ||
| 50 | ); | ||
| 51 | expect(errors).not.toContain("Error"); | ||
| 52 | expect(verified).toBe(true); | ||
| 53 | expect(result).toEqual({ code: 0, signal: null }); | ||
| 54 | } finally { | ||
| 55 | clearTimeout(watchdog); | ||
| 56 | if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); | ||
| 57 | for (const stream of child.stdio) stream?.destroy(); | ||
| 58 | } | ||
| 58 | }); | 59 | }); |
apps/server/src/preview/DesktopBrowserChannel.ts+4-2| ... | @@ -27,7 +27,7 @@ import * as Semaphore from "effect/Semaphore"; | ... | @@ -27,7 +27,7 @@ import * as Semaphore from "effect/Semaphore"; |
| 27 | import * as Stream from "effect/Stream"; | 27 | import * as Stream from "effect/Stream"; |
| 28 | import * as Ndjson from "effect/encoding/Ndjson"; | 28 | import * as Ndjson from "effect/encoding/Ndjson"; |
| 29 | import * as NodeCrypto from "node:crypto"; | 29 | import * as NodeCrypto from "node:crypto"; |
| 30 | import * as NodeFS from "node:fs"; | 30 | import * as NodeNet from "node:net"; |
| 31 | 31 | ||
| 32 | import * as ServerConfig from "../config.ts"; | 32 | import * as ServerConfig from "../config.ts"; |
| 33 | import { writeAllToFileDescriptor } from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; | 33 | import { writeAllToFileDescriptor } from "../resourceTelemetry/DesktopTelemetryReceiver.ts"; |
| ... | @@ -102,8 +102,10 @@ const make = Effect.gen(function* () { | ... | @@ -102,8 +102,10 @@ const make = Effect.gen(function* () { |
| 102 | ), | 102 | ), |
| 103 | ); | 103 | ); |
| 104 | 104 | ||
| 105 | // Socket reads can be cancelled while the desktop keeps its write end open. | ||
| 106 | // Filesystem reads on this pipe keep Node alive after scope shutdown. | ||
| 105 | const readable = yield* Effect.acquireRelease( | 107 | const readable = yield* Effect.acquireRelease( |
| 106 | Effect.sync(() => NodeFS.createReadStream("", { fd: inputFd, autoClose: true })), | 108 | Effect.sync(() => new NodeNet.Socket({ fd: inputFd, readable: true, writable: false })), |
| 107 | (stream) => Effect.sync(() => stream.destroy()), | 109 | (stream) => Effect.sync(() => stream.destroy()), |
| 108 | ); | 110 | ); |
| 109 | yield* NodeStream.fromReadable<Uint8Array, Error>({ | 111 | yield* NodeStream.fromReadable<Uint8Array, Error>({ |
apps/server/src/preview/testing/DesktopPipeLifecycle.fixture.ts created+57| ... | @@ -0,0 +1,57 @@ | ||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Entry point for the inherited-pipe shutdown regression test. | ||
| 2 | import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; | ||
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 4 | import * as Console from "effect/Console"; | ||
| 5 | import * as Context from "effect/Context"; | ||
| 6 | import * as Effect from "effect/Effect"; | ||
| 7 | import * as Exit from "effect/Exit"; | ||
| 8 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as Layer from "effect/Layer"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | |||
| 12 | import * as ServerSettings from "../../serverSettings.ts"; | ||
| 13 | |||
| 14 | import * as ServerConfig from "../../config.ts"; | ||
| 15 | import * as DesktopTelemetryReceiver from "../../resourceTelemetry/DesktopTelemetryReceiver.ts"; | ||
| 16 | import * as DesktopBrowserChannel from "../DesktopBrowserChannel.ts"; | ||
| 17 | |||
| 18 | const key = { threadId: "thread-1", tabId: "tab-1" }; | ||
| 19 | |||
| 20 | Effect.gen(function* () { | ||
| 21 | const base = yield* ServerConfig.ServerConfig.pipe( | ||
| 22 | Effect.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-pipe-lifecycle-" })), | ||
| 23 | ); | ||
| 24 | const context = yield* Layer.build( | ||
| 25 | Layer.merge(DesktopBrowserChannel.layer, DesktopTelemetryReceiver.layer).pipe( | ||
| 26 | Layer.provide(ServerSettings.layerTest()), | ||
| 27 | Layer.provide( | ||
| 28 | Layer.succeed(ServerConfig.ServerConfig, { | ||
| 29 | ...base, | ||
| 30 | desktopBrowserFd: 3, | ||
| 31 | desktopBrowserControlFd: 4, | ||
| 32 | desktopTelemetryFd: 5, | ||
| 33 | desktopTelemetryControlFd: 6, | ||
| 34 | }), | ||
| 35 | ), | ||
| 36 | ), | ||
| 37 | ); | ||
| 38 | const browser = Context.get(context, DesktopBrowserChannel.DesktopBrowserChannel); | ||
| 39 | const telemetry = Context.get(context, DesktopTelemetryReceiver.DesktopTelemetryReceiver); | ||
| 40 | const health = yield* telemetry.subscribeHealth; | ||
| 41 | const healthy = yield* health.changes.pipe( | ||
| 42 | Stream.filter((value) => value.status === "healthy"), | ||
| 43 | Stream.runHead, | ||
| 44 | Effect.forkScoped, | ||
| 45 | ); | ||
| 46 | const detached = yield* browser.detached.pipe(Stream.runHead, Effect.forkScoped); | ||
| 47 | yield* Console.log("ready"); | ||
| 48 | if (!(yield* browser.awaitAttached(key, "5 seconds"))) { | ||
| 49 | return yield* Effect.die("The browser attach message was not received."); | ||
| 50 | } | ||
| 51 | yield* Fiber.join(healthy); | ||
| 52 | yield* Console.log("attached"); | ||
| 53 | yield* Fiber.join(detached); | ||
| 54 | const endpoint = yield* Effect.exit(Effect.scoped(browser.endpoint(key))); | ||
| 55 | if (Exit.isSuccess(endpoint)) return yield* Effect.die("A detached tab received an endpoint."); | ||
| 56 | yield* Console.log("verified"); | ||
| 57 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer), NodeRuntime.runMain); | ||
apps/server/src/resourceTelemetry/DesktopTelemetryReceiver.ts+4-5| ... | @@ -1,5 +1,6 @@ | ... | @@ -1,5 +1,6 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off | 1 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | import * as NodeFS from "node:fs"; | 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodeNet from "node:net"; | ||
| 3 | 4 | ||
| 4 | import * as NodeStream from "@effect/platform-node/NodeStream"; | 5 | import * as NodeStream from "@effect/platform-node/NodeStream"; |
| 5 | import { | 6 | import { |
| ... | @@ -463,13 +464,11 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") | ... | @@ -463,13 +464,11 @@ export const make = Effect.fn("resourceTelemetry.desktopTelemetryReceiver.make") |
| 463 | 464 | ||
| 464 | if (config.desktopTelemetryFd !== undefined) { | 465 | if (config.desktopTelemetryFd !== undefined) { |
| 465 | const fd = config.desktopTelemetryFd; | 466 | const fd = config.desktopTelemetryFd; |
| 467 | // The parent waits for this server before closing its pipe. Socket reads | ||
| 468 | // can be cancelled; filesystem reads would keep process.exit waiting. | ||
| 466 | const readable = yield* Effect.acquireRelease( | 469 | const readable = yield* Effect.acquireRelease( |
| 467 | Effect.try({ | 470 | Effect.try({ |
| 468 | try: () => | 471 | try: () => new NodeNet.Socket({ fd, readable: true, writable: false }), |
| 469 | NodeFS.createReadStream("", { | ||
| 470 | fd, | ||
| 471 | autoClose: true, | ||
| 472 | }), | ||
| 473 | catch: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }), | 472 | catch: (cause) => new DesktopTelemetryStreamFailed({ fd, cause }), |
| 474 | }), | 473 | }), |
| 475 | (stream) => | 474 | (stream) => |