History

Defer MCP session tool syncs to waitUntil and pin the HTTP plane as inline

Rhys Sullivan committed 2 months ago

Commit 2f2c2b01 parent 936f95e4 files · +190 −23Browse files

Showing the changes to apps/cloud/src/engine/execution-stack.ts (1 of 4 files). Show all

Unified · Split

apps/cloud/src/engine/execution-stack.ts+73−18
@@ -41,6 +41,7 @@ import { · 40 unmodified lines
4141 HostConfig,
4242 PluginsProvider,
4343 collectTables,
44+ type HostConfigShape,
4445 } from "@executor-js/api/server";
4546 import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
4647 import type { AnyPlugin } from "@executor-js/sdk";
@@ -88,18 +89,69 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 41 unmodified lines
8889 */
8990 export const CLOUD_MOUNT_PREFIX = "/api" as const;
9091
91−export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({
92− // SSRF / private-network egress guard. Config-driven, NOT a test flag:
93− // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
94− // the e2e dev-server env opts in with `"true"` so in-scenario fixture
95− // servers on localhost are reachable. See `hosted-http-client.ts`.
96− allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
97− webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
98− oauthCallbackPath: `${CLOUD_MOUNT_PREFIX}/oauth/callback`,
99− // WorkOS Vault is cloud's credential storage implementation detail, not a
100− // user-selectable provider surface.
101− exposeCredentialProviders: false,
102−}));
92+/**
93+ * Cloud's host config, parameterized by the ONE seam its two execution planes
94+ * genuinely disagree about: where a deferred tool-catalog refresh runs.
95+ *
96+ * The MCP session Durable Object holds its database handle for the life of the
97+ * session, so it can hand the batch straight to `ctx.waitUntil` and forget it.
98+ * The HTTP plane's handle dies with its request and gets no seam at all (see
99+ * {@link CloudHostConfig}). Nothing else about the config differs, which is why
100+ * this is a parameter rather than a second config module.
101+ */
102+export const makeCloudHostConfig = (
103+ deferToolSync: HostConfigShape["deferToolSync"],
104+): Layer.Layer<HostConfig> =>
105+ Layer.sync(HostConfig, () => ({
106+ // SSRF / private-network egress guard. Config-driven, NOT a test flag:
107+ // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);
108+ // the e2e dev-server env opts in with `"true"` so in-scenario fixture
109+ // servers on localhost are reachable. See `hosted-http-client.ts`.
110+ allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",
111+ webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",
112+ oauthCallbackPath: `${CLOUD_MOUNT_PREFIX}/oauth/callback`,
113+ // WorkOS Vault is cloud's credential storage implementation detail, not a
114+ // user-selectable provider surface.
115+ exposeCredentialProviders: false,
116+ deferToolSync,
117+ }));
118+
119+/**
120+ * The HTTP plane's config, and the one seam it CANNOT fill: `deferToolSync` is
121+ * absent, so a tools read over `/api/*` still refreshes an expired catalog
122+ * inline.
123+ *
124+ * Not an omission. The postgres pool a request's executor closes over is
125+ * acquired and released inside `requestScopedMiddleware`'s `Effect.scoped`,
126+ * which wraps the ROUTE HANDLER — so the pool is torn down while the handler's
127+ * `HttpServerResponse` is still a value, strictly before `HttpEffect.toHandled`
128+ * writes it. There is therefore no window on this plane that is both after the
129+ * response and before the socket closes: work scheduled with `waitUntil` finds
130+ * a dead pool, and work drained inside the scope is simply inline work with
131+ * extra steps. `api.request-scope.node.test.ts` pins that ordering, so a future
132+ * change to the request lifecycle will say so rather than making this comment
133+ * quietly wrong.
134+ *
135+ * Holding the pool open past the response instead — deferring the scope close
136+ * into `waitUntil` — is the one thing that would make the window exist, and it
137+ * is the thing `closePostgres` was written to prevent. That finalizer awaits
138+ * the teardown precisely so live-plus-closing sockets stay bounded by what is
139+ * in flight; unbounded, the backlog is the sustained-load cascade recorded in
140+ * `db/db.ts`. Trading a socket leak for a background refresh is not a trade
141+ * this plane should make, and it would make it on EVERY request, not just the
142+ * ones that deferred.
143+ *
144+ * The alternative — giving the batch a short-lived pool of its own via
145+ * `makeDbLayer()` — does not fit either: the task the sdk hands over is already
146+ * bound to the executor that built it, so re-targeting it means rebuilding a
147+ * whole second executor in the background. That is a background scheduler, a
148+ * different feature with a different failure surface, not a wiring detail.
149+ *
150+ * Cloud's high-volume tools read is `tools/list` over MCP, and that plane DOES
151+ * defer: the session Durable Object holds its database handle for the life of
152+ * the session (see `mcp/session-durable-object.ts`).
153+ */
154+export const CloudHostConfig: Layer.Layer<HostConfig> = makeCloudHostConfig(undefined);
103155
104156 export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(
105157 CodeExecutorProvider,
@@ -114,13 +166,16 @@ export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Laye · 8 unmodified lines
114166 * exported so that overlay builds over the SAME four seams. There is no neutral
115167 * no-op-decorator variant anymore: every cloud execution meters.
116168 */
169+export const makeCloudExecutionSeamsLayer = (
170+ hostConfig: Layer.Layer<HostConfig>,
171+): Layer.Layer<
172+ DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider,
173+ never,
174+ DbService
175+> => Layer.mergeAll(CloudDbProvider, CloudPluginsProvider, hostConfig, CloudCodeExecutorProvider);
176+
117177 export const CloudExecutionSeamsLayer: Layer.Layer<
118178 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider,
119179 never,
120180 DbService
121−> = Layer.mergeAll(
122− CloudDbProvider,
123− CloudPluginsProvider,
124− CloudHostConfig,
125− CloudCodeExecutorProvider,
126−);
181+> = makeCloudExecutionSeamsLayer(CloudHostConfig);