diff --git a/packages/cloudflare-runtime/src/registry/Registry.ts b/packages/cloudflare-runtime/src/registry/Registry.ts index 85b0943a..1af34b32 100644 --- a/packages/cloudflare-runtime/src/registry/Registry.ts +++ b/packages/cloudflare-runtime/src/registry/Registry.ts @@ -127,23 +127,36 @@ export const RegistryLive = Layer.effect( ), write: (entry) => { const entryPath = path.join(directory, `${encodeURIComponent(entry.scriptName)}.json`); - return fs.writeFileString(entryPath, JSON.stringify(entry, null, 2)).pipe( + const serialized = JSON.stringify(entry, null, 2); + return fs.writeFileString(entryPath, serialized).pipe( Effect.andThen( // Immediately update the in-memory registry so it's available without waiting on IO. SubscriptionRef.update(ref, (map) => MutableHashMap.set(map, entry.scriptName, entry)), ), Effect.tap(() => { - // Remove the entry from the filesystem when the scope closes. + // Remove the entry from the filesystem when the scope closes — but + // only while the file still holds THIS write's content. A + // replacement instance of the same script re-registers under the + // same key; a graceful handoff closes the old scope after the new + // instance has already overwritten the file, and removing it here + // would unregister the live replacement. // If scope finalizers fail to run, a synchronous exit hook ensures the entry is removed. - const unregister = exitHook(() => { + const unregisterSync = () => { try { - NodeFs.unlinkSync(entryPath); + if (NodeFs.readFileSync(entryPath, "utf8") === serialized) { + NodeFs.unlinkSync(entryPath); + } } catch {} - }); + }; + const unregister = exitHook(unregisterSync); return Effect.addFinalizer(() => - fs - .remove(entryPath) - .pipe(Effect.andThen(Effect.sync(() => unregister())), Effect.ignore), + fs.readFileString(entryPath).pipe( + Effect.flatMap((current) => + current === serialized ? fs.remove(entryPath) : Effect.void, + ), + Effect.ignore, + Effect.andThen(Effect.sync(() => unregister())), + ), ); }), Effect.tap(() => diff --git a/packages/cloudflare-runtime/src/remote-bindings/RemoteWorker.ts b/packages/cloudflare-runtime/src/remote-bindings/RemoteWorker.ts index ec8816f1..a1900d7b 100644 --- a/packages/cloudflare-runtime/src/remote-bindings/RemoteWorker.ts +++ b/packages/cloudflare-runtime/src/remote-bindings/RemoteWorker.ts @@ -1,3 +1,4 @@ +import type { Credentials } from "@distilled.cloud/cloudflare/Credentials"; import * as workers from "@distilled.cloud/cloudflare/workers"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -20,7 +21,17 @@ export class RemoteWorker extends Context.Service< } >()("cloudflare-runtime/remote-bindings/RemoteWorker") {} -export const make = Effect.fn(function* (accountId: Effect.Effect) { +// Explicit annotation: the inferred type references `Credentials` from +// `@distilled.cloud/cloudflare`, which the dts bundler cannot name on its +// own — without this it degrades `make`/`layer` (and everything composed +// from them, e.g. `RuntimeServices.layerRuntime`) to `any, any`. +export const make: ( + accountId: Effect.Effect, +) => Effect.Effect< + RemoteWorker["Service"], + never, + Access.Access | Credentials | HttpClient.HttpClient +> = Effect.fn(function* (accountId: Effect.Effect) { const http = yield* HttpClient.HttpClient; const access = yield* Access.Access; @@ -123,7 +134,9 @@ export const make = Effect.fn(function* (accountId: Effect.Effect) { }); }); -export const layer = (accountId: Effect.Effect) => +export const layer = ( + accountId: Effect.Effect, +): Layer.Layer => Layer.effect(RemoteWorker, make(accountId)); /** diff --git a/packages/cloudflare-runtime/test/registry/Registry.test.ts b/packages/cloudflare-runtime/test/registry/Registry.test.ts index 5e3958da..7d57914c 100644 --- a/packages/cloudflare-runtime/test/registry/Registry.test.ts +++ b/packages/cloudflare-runtime/test/registry/Registry.test.ts @@ -82,6 +82,39 @@ describe.each(watcherModes)( }).pipe(Effect.provide(services)), ); + // Make-before-break handoff: a replacement instance re-registers under + // the same script name before the old instance's scope closes. The old + // instance's unregister finalizer must not delete the replacement's + // registration — removal is owner-aware (only removes the file while it + // still holds that write's content). + it.live("unregistering a superseded entry keeps the replacement registration", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const registry = yield* Registry.Registry; + const { entryPath, registryEntry } = yield* makeTestData("7"); + const replacementEntry: RegistryEntry = { + ...registryEntry, + debugPortAddress: "127.0.0.1:23456", + }; + + const oldScope = yield* Scope.make(); + yield* registry.write(registryEntry).pipe(Scope.provide(oldScope)); + + const replacementScope = yield* Scope.make(); + yield* registry.write(replacementEntry).pipe(Scope.provide(replacementScope)); + + // Closing the superseded instance's scope must not delete the file — + // it now belongs to the replacement. + yield* Scope.close(oldScope, Exit.void); + expect(yield* fs.exists(entryPath)).toBe(true); + expect(JSON.parse(yield* fs.readFileString(entryPath))).toEqual(replacementEntry); + + // Closing the replacement's own scope removes it. + yield* Scope.close(replacementScope, Exit.void); + expect(yield* fs.exists(entryPath)).toBe(false); + }).pipe(Effect.provide(services)), + ); + it.live("read skips entries that don't match the subscriber", () => Effect.gen(function* () { const registry = yield* Registry.Registry;