-
Notifications
You must be signed in to change notification settings - Fork 137
🤖 refactor: Effect Phase 11 PR 1 — ManagedRuntime skeleton (AppRuntime + Stores/MemoryMeta layers + runtime-backed effect/context) #4049
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,114 @@ | ||
| import { describe, expect, it, spyOn } from "bun:test"; | ||
| import { Context, Effect, Layer } from "effect"; | ||
| import { log } from "@/node/services/log"; | ||
| import { disposeAppRuntime, makeAppRuntime } from "./appRuntime"; | ||
|
|
||
| class ProbeA extends Context.Service<ProbeA, { readonly name: string }>()("test/ProbeA") {} | ||
| class ProbeB extends Context.Service<ProbeB, { readonly name: string }>()("test/ProbeB") {} | ||
|
|
||
| describe("makeAppRuntime", () => { | ||
| it("builds a synchronous layer graph eagerly and caches the context", () => { | ||
| const app = makeAppRuntime(Layer.succeed(ProbeA)({ name: "a" })); | ||
|
|
||
| expect(app.managed.cachedContext).toBeDefined(); | ||
| expect(app.get(ProbeA).name).toBe("a"); | ||
| expect(Context.get(app.context, ProbeA)).toBe(app.get(ProbeA)); | ||
| }); | ||
|
|
||
| it("throws synchronously when a layer body suspends", () => { | ||
| const asyncLayer = Layer.effect( | ||
| ProbeA, | ||
| Effect.promise(() => Promise.resolve({ name: "late" })) | ||
| ); | ||
|
|
||
| expect(() => makeAppRuntime(asyncLayer)).toThrow(); | ||
| }); | ||
|
|
||
| it("propagates a throwing layer body as a synchronous throw", () => { | ||
| const throwingLayer = Layer.sync(ProbeA, () => { | ||
| throw new Error("constructor boom"); | ||
| }); | ||
|
|
||
| expect(() => makeAppRuntime(throwingLayer)).toThrow("constructor boom"); | ||
| }); | ||
|
|
||
| it("starts fibers synchronously after the eager build", () => { | ||
| const app = makeAppRuntime(Layer.succeed(ProbeA)({ name: "a" })); | ||
| let ran = false; | ||
|
|
||
| app.managed.runFork( | ||
| Effect.sync(() => { | ||
| ran = true; | ||
| }) | ||
| ); | ||
|
|
||
| // runFork on a built runtime is Effect.runForkWith(cachedContext): the body | ||
| // executes before runFork returns, up to its first async boundary. | ||
| expect(ran).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe("disposeAppRuntime", () => { | ||
| it("runs layer finalizers in reverse acquisition order", async () => { | ||
| const order: string[] = []; | ||
| const release = (name: string) => | ||
| Effect.sync(() => { | ||
| order.push(name); | ||
| }); | ||
| const a = Layer.effect( | ||
| ProbeA, | ||
| Effect.acquireRelease(Effect.succeed({ name: "a" }), () => release("a")) | ||
| ); | ||
| const b = Layer.effect( | ||
| ProbeB, | ||
| Effect.acquireRelease(Effect.succeed({ name: "b" }), () => release("b")) | ||
| ); | ||
| // B is provided with A, so A is acquired first and must be released last. | ||
| const app = makeAppRuntime(b.pipe(Layer.provideMerge(a))); | ||
|
|
||
| await disposeAppRuntime(app.managed); | ||
|
|
||
| expect(order).toEqual(["b", "a"]); | ||
| }); | ||
|
|
||
| it("is idempotent", async () => { | ||
| let released = 0; | ||
| const app = makeAppRuntime( | ||
| Layer.effect( | ||
| ProbeA, | ||
| Effect.acquireRelease(Effect.succeed({ name: "a" }), () => | ||
| Effect.sync(() => { | ||
| released += 1; | ||
| }) | ||
| ) | ||
| ) | ||
| ); | ||
|
|
||
| await disposeAppRuntime(app.managed); | ||
| await disposeAppRuntime(app.managed); | ||
|
|
||
| expect(released).toBe(1); | ||
| expect(app.managed.cachedContext).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("returns at the timeout when a finalizer hangs, warning instead of rejecting", async () => { | ||
| const warnSpy = spyOn(log, "warn").mockImplementation(() => undefined); | ||
| try { | ||
| const app = makeAppRuntime( | ||
| Layer.effect( | ||
| ProbeA, | ||
| Effect.acquireRelease(Effect.succeed({ name: "a" }), () => Effect.never) | ||
| ) | ||
| ); | ||
|
|
||
| const startedAt = Date.now(); | ||
| await disposeAppRuntime(app.managed, 50); | ||
|
|
||
| expect(Date.now() - startedAt).toBeLessThan(2_000); | ||
| expect(warnSpy).toHaveBeenCalledTimes(1); | ||
| expect(String(warnSpy.mock.calls[0]?.[0])).toContain("timed out"); | ||
| } finally { | ||
| warnSpy.mockRestore(); | ||
| } | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| /** | ||
| * App-lifetime Effect runtime (Effect migration Phase 11). | ||
| * | ||
| * A `ManagedRuntime` built from the process's Layer graph (`./layers/app.ts` | ||
| * for `ServiceContainer` roots). It owns the app-lifetime `Scope`, and its | ||
| * built `Context` is what oRPC Effect-native handlers receive as | ||
| * `"effect/context"`. | ||
| * | ||
| * DI contract (Phase 11 compatibility rules, not permanent architecture law): | ||
| * | ||
| * - **Synchronous layer bodies.** Every Layer in the graph must build without | ||
| * suspending (`Layer.succeed`/`Layer.sync`/`Layer.effect` over sync effects; | ||
| * `acquireRelease` with a sync acquire is fine). `makeAppRuntime` builds the | ||
| * graph eagerly with `runSync` and asserts that it completed, so a layer that | ||
| * suspends fails right here — at construction, exactly where a throwing | ||
| * service constructor fails today, and therefore inside every entry point's | ||
| * existing startup catch path (`desktop/main.ts` dialog, `cli/server.ts`/ACP | ||
| * log-and-exit). Asynchronous acquisition belongs in `initialize()` or a | ||
| * later explicit async factory root, never silently inside a layer. Eager | ||
| * building also keeps fibers started through the runtime synchronous up to | ||
| * their first async boundary (`cachedContext` is set, so `runX` is | ||
| * `Effect.run…With(context)`), which the deterministic-winner funnels in the | ||
| * codebase rely on. | ||
| * - **Only the composition root holds the runtime.** Services must not be | ||
| * handed the `ManagedRuntime`: after `dispose()` every `runX` on it dies with | ||
| * "ManagedRuntime disposed", and a late worker callback would defect. | ||
| * - **No layer finalizers yet.** Teardown order stays explicit in | ||
| * `ServiceContainer.dispose()`; layer bodies register no finalizers, so | ||
| * `disposeAppRuntime` (the last dispose step) reorders nothing. It is wired | ||
| * now so that later phases (the streamManager engine core) have a fixed, | ||
| * bounded slot for scope-owned resources. | ||
| */ | ||
| import assert from "@/common/utils/assert"; | ||
| import { Context, Duration, Effect, Fiber, ManagedRuntime } from "effect"; | ||
| import type { Layer } from "effect"; | ||
| import { APP_RUNTIME_DISPOSE_TIMEOUT_MS } from "@/constants/terminationTimeouts"; | ||
| import { log } from "@/node/services/log"; | ||
|
|
||
| export interface AppRuntime<R> { | ||
| /** The runtime that owns the layer scope. Composition roots only (see contract). */ | ||
| readonly managed: ManagedRuntime.ManagedRuntime<R, never>; | ||
| /** The built service context; also the oRPC `"effect/context"`. */ | ||
| readonly context: Context.Context<R>; | ||
| /** Resolve a service instance from the built context. */ | ||
| readonly get: <I extends R, S>(tag: Context.Key<I, S>) => S; | ||
| } | ||
|
|
||
| /** | ||
| * Build the runtime for `layer` eagerly and synchronously. Throws (constructor | ||
| * semantics) if the graph cannot be built without suspending or a layer body | ||
| * throws. | ||
| */ | ||
| export function makeAppRuntime<R>(layer: Layer.Layer<R, never, never>): AppRuntime<R> { | ||
| const startedAt = performance.now(); | ||
| const managed = ManagedRuntime.make(layer); | ||
| const context = managed.runSync(Effect.context<R>()); | ||
| assert( | ||
| managed.cachedContext !== undefined, | ||
| "AppRuntime layer graph must build synchronously (see DI contract in di/appRuntime.ts)" | ||
| ); | ||
| log.debug("[startup] AppRuntime built", { ms: Math.round(performance.now() - startedAt) }); | ||
| return { | ||
| managed, | ||
| context, | ||
| get: (tag) => Context.get(context, tag), | ||
| }; | ||
| } | ||
|
|
||
| /** | ||
| * Close the runtime's scope (interrupting fibers started through it and running | ||
| * layer finalizers in reverse order), bounded by `timeoutMs`. Never rejects and | ||
| * is idempotent: a second call finds the scope already closed and returns. | ||
| * | ||
| * The close runs in a detached fiber and the caller waits on its join, so a | ||
| * hung finalizer cannot pin shutdown past the bound — the wait is interrupted, | ||
| * the finalizer keeps running best-effort, and a warning is logged. | ||
| */ | ||
| export function disposeAppRuntime( | ||
| runtime: ManagedRuntime.ManagedRuntime<never, never>, | ||
| timeoutMs: number = APP_RUNTIME_DISPOSE_TIMEOUT_MS | ||
| ): Promise<void> { | ||
| return Effect.runPromise(disposeAppRuntimeEffect(runtime, timeoutMs)); | ||
| } | ||
|
|
||
| function disposeAppRuntimeEffect( | ||
| runtime: ManagedRuntime.ManagedRuntime<never, never>, | ||
| timeoutMs: number | ||
| ): Effect.Effect<void> { | ||
| const startedAt = performance.now(); | ||
| // Uninterruptible teardown shell; only the bounded wait inside is interruptible | ||
| // so the timeout can win the race (house shape from #4038). | ||
| return Effect.uninterruptible( | ||
| Effect.gen(function* () { | ||
| const closing = yield* Effect.forkDetach(runtime.disposeEffect); | ||
| yield* Effect.interruptible( | ||
| Fiber.join(closing).pipe(Effect.timeout(Duration.millis(timeoutMs))) | ||
| ).pipe( | ||
| Effect.catchTag("TimeoutError", () => | ||
| Effect.sync(() => { | ||
| log.warn("[shutdown] AppRuntime dispose timed out; finalizers continue best-effort", { | ||
| timeoutMs, | ||
| }); | ||
| }) | ||
| ) | ||
| ); | ||
| log.debug("[shutdown] AppRuntime disposed", { | ||
| ms: Math.round(performance.now() - startedAt), | ||
| }); | ||
| }).pipe( | ||
| Effect.catchDefect((defect) => | ||
| Effect.sync(() => { | ||
| log.warn("[shutdown] AppRuntime dispose failed", { error: defect }); | ||
| }) | ||
| ) | ||
| ) | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { Layer } from "effect"; | ||
| import type { ConfigStores } from "@/node/config"; | ||
| import type { AppTags } from "@/node/services/di/tags"; | ||
| import { MemoryMetaLive } from "./core"; | ||
| import { StoresLive } from "./stores"; | ||
|
|
||
| /** | ||
| * Full Layer graph for a `ServiceContainer` process (desktop, `xum server`, | ||
| * ACP, tests/ipc, headless bench). | ||
| * | ||
| * Composition direction: `consumer.pipe(Layer.provideMerge(provider))` — the | ||
| * right-hand operand satisfies the left-hand operand's requirements and both | ||
| * stay exposed in the final context. `Layer.mergeAll` is only for true | ||
| * siblings; it does not satisfy one sibling's requirements from another. | ||
| */ | ||
| export function AppLive(stores: ConfigStores): Layer.Layer<AppTags> { | ||
| return MemoryMetaLive.pipe(Layer.provideMerge(StoresLive(stores))); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| import { Effect, Layer } from "effect"; | ||
| import { ConfigTag, MemoryMeta } from "@/node/services/di/tags"; | ||
| import { MemoryMetaService } from "@/node/services/memoryMeta"; | ||
|
|
||
| /** | ||
| * Layers for the core service graph shared by the desktop/server app and the | ||
| * headless CLI roots. Bodies are thin adapters around the existing constructors | ||
| * and must stay synchronous (see the DI contract in `../appRuntime.ts`). | ||
| */ | ||
|
|
||
| /** Memory metadata sidecar; scope root derives from the xum home (`config.rootDir`). */ | ||
| export const MemoryMetaLive: Layer.Layer<MemoryMeta, never, ConfigTag> = Layer.effect( | ||
| MemoryMeta, | ||
| Effect.map(ConfigTag, (config) => new MemoryMetaService(config.rootDir)) | ||
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { Layer } from "effect"; | ||
| import type { ConfigStores } from "@/node/config"; | ||
| import { | ||
| ConfigTag, | ||
| FileLeaseManagerTag, | ||
| ProvidersConfigStoreTag, | ||
| SecretsStoreTag, | ||
| SessionLocatorTag, | ||
| type StoreTags, | ||
| } from "@/node/services/di/tags"; | ||
|
|
||
| /** | ||
| * Exposes an already-constructed `ConfigStores` bundle as Layer outputs. The | ||
| * stores are true siblings (no inter-dependencies), so `mergeAll` is correct | ||
| * here; anything that depends on a store must be composed with | ||
| * `Layer.provideMerge` instead. | ||
| */ | ||
| export function StoresLive(stores: ConfigStores): Layer.Layer<StoreTags> { | ||
| return Layer.mergeAll( | ||
| Layer.succeed(ConfigTag)(stores.config), | ||
| Layer.succeed(SessionLocatorTag)(stores.sessionLocator), | ||
| Layer.succeed(ProvidersConfigStoreTag)(stores.providersConfigStore), | ||
| Layer.succeed(SecretsStoreTag)(stores.secretsStore), | ||
| Layer.succeed(FileLeaseManagerTag)(stores.fileLeaseManager) | ||
| ); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.