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
33 changes: 26 additions & 7 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,27 +485,46 @@ export const router = (authToken?: string) => {
.input(schemas.providers.getConfig.input)
.output(schemas.providers.getConfig.output)
.handler(({ context }) => context.providerService.getConfig()),
// Provider mutations run Effect generators via handlerGen; the wire
// contracts are unchanged. The service pipelines are uninterruptible
// (see asAtomicMutation in providerService.ts), so a client abort
// cannot strand a persisted write without its post-write steps. Sync
// reads (list/getConfig) and subscriptions stay plain handlers.
addCustomProvider: t
.input(schemas.providers.addCustomProvider.input)
.output(schemas.providers.addCustomProvider.output)
.handler(({ context, input }) => context.providerService.addCustomProvider(input)),
.handler(
handlerGen(function* ({ context }, input) {
return yield* context.providerService.addCustomProviderEffect(input);
})
),
removeCustomProvider: t
.input(schemas.providers.removeCustomProvider.input)
.output(schemas.providers.removeCustomProvider.output)
.handler(({ context, input }) =>
context.providerService.removeCustomProvider(input.provider)
.handler(
handlerGen(function* ({ context }, input) {
return yield* context.providerService.removeCustomProviderEffect(input.provider);
})
),
setProviderConfig: t
.input(schemas.providers.setProviderConfig.input)
.output(schemas.providers.setProviderConfig.output)
.handler(({ context, input }) =>
context.providerService.setConfig(input.provider, input.keyPath, input.value)
.handler(
handlerGen(function* ({ context }, input) {
return yield* context.providerService.setConfigEffect(
input.provider,
input.keyPath,
input.value
);
})
),
setModels: t
.input(schemas.providers.setModels.input)
.output(schemas.providers.setModels.output)
.handler(({ context, input }) =>
context.providerService.setModels(input.provider, input.models)
.handler(
handlerGen(function* ({ context }, input) {
return yield* context.providerService.setModelsEffect(input.provider, input.models);
})
),
onConfigChanged: t
.input(schemas.providers.onConfigChanged.input)
Expand Down
36 changes: 36 additions & 0 deletions src/node/services/providerService.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FileLeaseManager, ProvidersConfigStore } from "@/node/config";
import { describe, expect, it, spyOn } from "bun:test";
import { Effect, Fiber } from "effect";
import * as fs from "fs";
import * as fsPromises from "fs/promises";
import { writeFile } from "node:fs/promises";
Expand Down Expand Up @@ -2347,3 +2348,38 @@ describe("ProviderService gateway lifecycle", () => {
});
});
});

describe("ProviderService mutation interruption", () => {
it("runs the write and post-write steps to completion when interrupted mid-mutation", async () => {
await withTempConfigAsync(async (config, service) => {
await saveRoutePriority(config, ["direct"]);
let notified = 0;
const unsubscribe = service.onConfigChanged(() => {
notified += 1;
});
try {
// runFork executes synchronously up to the first async yield (the
// providers-file lock); interrupting there mirrors an oRPC client
// abort landing while the mutation is in flight (handlerGen
// interrupts the handler fiber on abort).
const fiber = Effect.runFork(
service.setConfigEffect("mux-gateway", ["couponCode"], "gateway-token")
);
await Effect.runPromise(Fiber.interrupt(fiber));

// The mutation pipeline is uninterruptible: the persisted write, the
// change notification, and the gateway routePriority sync must all
// have completed β€” a write that lands without its post-write steps
// would leave observers and routing state inconsistent.
const stored = new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.[
"mux-gateway"
] as Record<string, unknown>;
expect(stored.couponCode).toBe("gateway-token");
expect(notified).toBe(1);
expect(config.loadConfigOrDefault().routePriority).toEqual(["mux-gateway", "direct"]);
} finally {
unsubscribe();
}
});
});
});
Loading
Loading