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
41 HostConfig,41 HostConfig,
42 PluginsProvider,42 PluginsProvider,
43 collectTables,43 collectTables,
44 type HostConfigShape,
44} from "@executor-js/api/server";45} from "@executor-js/api/server";
45import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";46import { makeDynamicWorkerExecutor } from "@executor-js/runtime-dynamic-worker";
46import type { AnyPlugin } from "@executor-js/sdk";47import type { AnyPlugin } from "@executor-js/sdk";
@@ -88,18 +89,69 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 41 unmodified lines
88 */89 */
89export const CLOUD_MOUNT_PREFIX = "/api" as const;90export const CLOUD_MOUNT_PREFIX = "/api" as const;
90 91
91export const CloudHostConfig: Layer.Layer<HostConfig> = Layer.sync(HostConfig, () => ({92/**
92 // SSRF / private-network egress guard. Config-driven, NOT a test flag:93 * Cloud's host config, parameterized by the ONE seam its two execution planes
93 // production leaves `ALLOW_LOCAL_NETWORK` unset so the guard stays ON (`false`);94 * genuinely disagree about: where a deferred tool-catalog refresh runs.
94 // the e2e dev-server env opts in with `"true"` so in-scenario fixture95 *
95 // servers on localhost are reachable. See `hosted-http-client.ts`.96 * The MCP session Durable Object holds its database handle for the life of the
96 allowLocalNetwork: env.ALLOW_LOCAL_NETWORK === "true",97 * session, so it can hand the batch straight to `ctx.waitUntil` and forget it.
97 webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh",98 * The HTTP plane's handle dies with its request and gets no seam at all (see
98 oauthCallbackPath: `${CLOUD_MOUNT_PREFIX}/oauth/callback`,99 * {@link CloudHostConfig}). Nothing else about the config differs, which is why
99 // WorkOS Vault is cloud's credential storage implementation detail, not a100 * this is a parameter rather than a second config module.
100 // user-selectable provider surface.101 */
101 exposeCredentialProviders: false,102export const makeCloudHostConfig = (
102}));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 */
154export const CloudHostConfig: Layer.Layer<HostConfig> = makeCloudHostConfig(undefined);
103 155
104export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(156export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Layer.sync(
105 CodeExecutorProvider,157 CodeExecutorProvider,
@@ -114,13 +166,16 @@ export const CloudCodeExecutorProvider: Layer.Layer<CodeExecutorProvider> = Laye · 8 unmodified lines
114 * exported so that overlay builds over the SAME four seams. There is no neutral166 * exported so that overlay builds over the SAME four seams. There is no neutral
115 * no-op-decorator variant anymore: every cloud execution meters.167 * no-op-decorator variant anymore: every cloud execution meters.
116 */168 */
169export 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
117export const CloudExecutionSeamsLayer: Layer.Layer<177export const CloudExecutionSeamsLayer: Layer.Layer<
118 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider,178 DbProvider | PluginsProvider | HostConfig | CodeExecutorProvider,
119 never,179 never,
120 DbService180 DbService
121> = Layer.mergeAll(181> = makeCloudExecutionSeamsLayer(CloudHostConfig);
122 CloudDbProvider,
123 CloudPluginsProvider,
124 CloudHostConfig,
125 CloudCodeExecutorProvider,
126);