Skip to content

Commit b94f537

Browse files
Benoit Traversclaude
authored andcommitted
fix(worker): catch non-Promise thenables and hostile brand reads
Addresses both review comments on #357. `bindQueryHandler` / `bindUpdateHandler`'s per-call sync guards used `instanceof Promise` while the bind-time probe used `isThenable`. Standard Schema types the async branch as `Promise<Result>`, but an implementation may hand back any `PromiseLike` — that slipped past `instanceof Promise`, and the helper then read `.issues` (undefined, i.e. "valid") and `.value` (undefined) straight off the thenable, handing the handler an unvalidated `undefined` instead of tripping `ContractMisuseError`. Both call sites now share an `isAsyncValidation` helper that matches the probe's check and detaches the thenable's settlement. `_internal_declaredWorkflowName` read a symbol property off an arbitrary module export, so a `Proxy` with a throwing `get` trap (or a throwing getter) aborted the whole workflow-registration check with an unrelated exception. The read is now guarded — anything that fails to yield a string brand is simply "not a declared workflow". Also fixes the red Integration Tests job: turbo ran the four `test:integration` tasks in parallel, each spinning up its own Temporal + Postgres testcontainer pair, and the contention pushed the client suite's fixture setup past Vitest's 10s `hookTimeout` default. Serialize the task (the workaround already documented in the PR description) and give the integration projects a `hookTimeout` sized for fixture setup that opens Temporal connections and builds a workflow bundle per worker. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6d77137 commit b94f537

9 files changed

Lines changed: 166 additions & 6 deletions

File tree

examples/order-processing-worker/vitest.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ export default defineConfig({
55
globalSetup: "@temporal-contract/testing/global-setup",
66
reporters: ["default"],
77
setupFiles: ["./src/vitest.setup.ts"],
8+
// These specs drive a real Temporal server through testcontainers, and
9+
// fixture setup builds a workflow bundle per worker. Vitest's 5s test /
10+
// 10s hook defaults are sized for unit tests, not for that.
11+
testTimeout: 30_000,
12+
hookTimeout: 60_000,
813
coverage: {
914
provider: "v8",
1015
reporter: ["text", "json", "json-summary", "html"],

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"prepare": "lefthook install",
1717
"release": "pnpm build && changeset publish",
1818
"test": "turbo run test",
19-
"test:integration": "turbo run test:integration",
19+
"test:integration": "turbo run test:integration --concurrency=1",
2020
"typecheck": "turbo run typecheck",
2121
"version": "changeset version"
2222
},

packages/client/vitest.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export default defineConfig({
2323
globalSetup: "@temporal-contract/testing/global-setup",
2424
include: ["src/**/__tests__/*.spec.ts"],
2525
testTimeout: 10_000,
26+
// The per-test fixtures open three Temporal connections and build a
27+
// workflow bundle per worker before the test body runs. Vitest's
28+
// 10s hook default was never sized for that — on a loaded CI runner
29+
// it trips before anything is actually wrong. `testTimeout` above
30+
// still bounds the test body itself.
31+
hookTimeout: 60_000,
2632
setupFiles: ["./src/vitest.setup.ts"],
2733
},
2834
},

packages/testing/vitest.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,10 @@ export default defineConfig({
6262
include: ["src/**/__tests__/*.spec.ts"],
6363
setupFiles: ["./src/vitest.setup.ts"],
6464
testTimeout: 30_000,
65+
// Fixture setup opens Temporal connections and builds a workflow
66+
// bundle before the test body runs; Vitest's 10s hook default is
67+
// too tight for that on a loaded CI runner.
68+
hookTimeout: 60_000,
6569
},
6670
},
6771
],

packages/worker/src/handlers.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,19 @@ const asyncStringSchema = {
7777
},
7878
};
7979

80+
/**
81+
* A validation result that is `PromiseLike` but NOT a `Promise` — the shape
82+
* an `instanceof Promise` guard misses. The cast is the point: Standard
83+
* Schema *types* the async branch as `Promise<Result>`, so only a runtime
84+
* check can catch an implementation (a JS consumer, another realm's promise,
85+
* a deferred wrapper) that returns a plain thenable instead.
86+
*/
87+
function bareThenable(value: unknown): Promise<{ value: unknown; issues: undefined }> {
88+
// oxlint-disable-next-line unicorn/no-thenable -- the thenable IS the fixture: this test proves the sync guard catches a non-Promise PromiseLike
89+
const thenable = { then: (resolve: (r: unknown) => void) => resolve({ value }) };
90+
return thenable as unknown as Promise<{ value: unknown; issues: undefined }>;
91+
}
92+
8093
const workflow = defineWorkflow({
8194
input: z.object({ id: z.string() }),
8295
output: z.object({}),
@@ -302,6 +315,37 @@ describe("bindQueryHandler", () => {
302315
expect(() => entry.impl(["x"])).toThrow(/validation must be synchronous/);
303316
});
304317

318+
it("the per-call guard catches a non-Promise thenable, not just a native Promise", () => {
319+
// Standard Schema types the async signature as `Promise<Result>`, but an
320+
// implementation may hand back any PromiseLike. An `instanceof Promise`
321+
// guard would wave this through, and the helper would then read `.issues`
322+
// (undefined → "valid") and `.value` (undefined) off the thenable and pass
323+
// an unvalidated `undefined` to the handler.
324+
captured.length = 0;
325+
const thenableDodgingSchema = {
326+
"~standard": {
327+
version: 1 as const,
328+
vendor: "test-thenable",
329+
validate: (input: unknown) =>
330+
typeof input === "symbol" ? { value: input, issues: undefined } : bareThenable(input),
331+
},
332+
};
333+
const wf = defineWorkflow({
334+
input: z.object({}),
335+
output: z.object({}),
336+
queries: {
337+
progress: { input: thenableDodgingSchema, output: z.number() },
338+
},
339+
});
340+
const handler = vi.fn().mockReturnValue(1);
341+
bindQueryHandler(wf, "probe", "progress", handler as never);
342+
const entry = captured.find((c) => c.kind === "query" && c.name === "progress")!;
343+
expect(() => entry.impl(["x"])).toThrow(ContractMisuseError);
344+
expect(() => entry.impl(["x"])).toThrow(/validation must be synchronous/);
345+
// The handler never ran on the unvalidated payload.
346+
expect(handler).not.toHaveBeenCalled();
347+
});
348+
305349
it("throws ContractMisuseError when the workflow has no queries block", () => {
306350
const noQueries = defineWorkflow({
307351
input: z.object({}),

packages/worker/src/handlers.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,31 @@ function isThenable(value: unknown): value is PromiseLike<unknown> {
8585
);
8686
}
8787

88+
/**
89+
* Per-call guard for the sync-only schema slots, kept shape-compatible with
90+
* {@link assertSyncSchema}'s probe.
91+
*
92+
* Standard Schema types the async signature as `Promise<Result>`, but an
93+
* implementation may legally hand back any `PromiseLike` — a wrapper, a
94+
* deferred, a `then`-able from another realm. `instanceof Promise` misses
95+
* those, and the caller would then read `.issues` (`undefined` → "no issues")
96+
* and `.value` (`undefined`) straight off the thenable, silently handing the
97+
* handler an unvalidated `undefined` instead of tripping
98+
* {@link ContractMisuseError}. Matching the probe's `isThenable` closes that.
99+
*
100+
* A detected thenable's settlement is detached for the same reason the probe
101+
* detaches its own: nothing awaits it, and a rejection would otherwise surface
102+
* as an unhandled rejection while the {@link ContractMisuseError} is in flight.
103+
*/
104+
function isAsyncValidation(result: unknown): result is PromiseLike<unknown> {
105+
if (!isThenable(result)) return false;
106+
result.then(
107+
() => undefined,
108+
() => undefined,
109+
);
110+
return true;
111+
}
112+
88113
/**
89114
* Bind-time probe for the sync-only schema slots (query input/output, update
90115
* input). Standard Schema permits `validate` to return a Promise (e.g. Zod
@@ -253,7 +278,7 @@ export function bindQueryHandler(
253278
const input = extractHandlerInput(args);
254279
const inputResult = queryDef.input["~standard"].validate(input);
255280

256-
if (inputResult instanceof Promise) {
281+
if (isAsyncValidation(inputResult)) {
257282
// oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception)
258283
throw new ContractMisuseError(
259284
`Query "${queryName}" validation must be synchronous. Use a schema library that supports synchronous validation for queries.`,
@@ -267,7 +292,7 @@ export function bindQueryHandler(
267292
const result = handler(inputResult.value);
268293

269294
const outputResult = queryDef.output["~standard"].validate(result);
270-
if (outputResult instanceof Promise) {
295+
if (isAsyncValidation(outputResult)) {
271296
// oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception)
272297
throw new ContractMisuseError(
273298
`Query "${queryName}" output validation must be synchronous. Use a schema library that supports synchronous validation for queries.`,
@@ -360,7 +385,7 @@ export function bindUpdateHandler(
360385
// worth surfacing.
361386
const input = extractHandlerInput(args);
362387
const inputResult = updateDef.input["~standard"].validate(input);
363-
if (inputResult instanceof Promise) {
388+
if (isAsyncValidation(inputResult)) {
364389
// oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception)
365390
throw new ContractMisuseError(updateInputMustBeSynchronousMessage(updateName));
366391
}
@@ -390,7 +415,7 @@ export function bindUpdateHandler(
390415
const input = extractHandlerInput(args);
391416
const inputResult = updateDef.input["~standard"].validate(input);
392417

393-
if (inputResult instanceof Promise) {
418+
if (isAsyncValidation(inputResult)) {
394419
// oxlint-disable-next-line unthrown/no-throw -- sanctioned ContractMisuseError model: non-retryable ApplicationFailure Temporal must see thrown (CLAUDE.md rule 2 exception)
395420
throw new ContractMisuseError(updateInputMustBeSynchronousMessage(updateName));
396421
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Coverage for the `declareWorkflow` brand reader used by `TypedWorker`'s
3+
* workflow-registration check.
4+
*
5+
* The reader is fed arbitrary module exports, so every assertion here is
6+
* about it staying total: non-functions, unbranded functions, non-string
7+
* brands, and — the case that matters most — exports whose property read
8+
* itself throws. A throwing read must degrade to "not a declared workflow",
9+
* not abort the registration check with an unrelated exception.
10+
*/
11+
import { describe, expect, it } from "vitest";
12+
13+
import { DECLARED_WORKFLOW_BRAND, _internal_declaredWorkflowName } from "./workflow-brand.js";
14+
15+
describe("_internal_declaredWorkflowName", () => {
16+
it("returns the declared name for a branded function", () => {
17+
const branded = Object.assign(() => undefined, {
18+
[DECLARED_WORKFLOW_BRAND]: "orderWorkflow",
19+
});
20+
expect(_internal_declaredWorkflowName(branded)).toBe("orderWorkflow");
21+
});
22+
23+
it("returns undefined for non-functions and unbranded functions", () => {
24+
expect(_internal_declaredWorkflowName(undefined)).toBeUndefined();
25+
expect(_internal_declaredWorkflowName(null)).toBeUndefined();
26+
expect(_internal_declaredWorkflowName("orderWorkflow")).toBeUndefined();
27+
expect(
28+
_internal_declaredWorkflowName({ [DECLARED_WORKFLOW_BRAND]: "notAFunction" }),
29+
).toBeUndefined();
30+
expect(_internal_declaredWorkflowName(() => undefined)).toBeUndefined();
31+
});
32+
33+
it("returns undefined when the brand is present but not a string", () => {
34+
const branded = Object.assign(() => undefined, { [DECLARED_WORKFLOW_BRAND]: 42 });
35+
expect(_internal_declaredWorkflowName(branded)).toBeUndefined();
36+
});
37+
38+
it("returns undefined instead of propagating a throwing getter", () => {
39+
const hostile = () => undefined;
40+
Object.defineProperty(hostile, DECLARED_WORKFLOW_BRAND, {
41+
get() {
42+
// oxlint-disable-next-line unthrown/no-throw -- test double: simulates a module export whose property read throws
43+
throw new Error("hostile getter");
44+
},
45+
});
46+
expect(() => _internal_declaredWorkflowName(hostile)).not.toThrow();
47+
expect(_internal_declaredWorkflowName(hostile)).toBeUndefined();
48+
});
49+
50+
it("returns undefined instead of propagating a Proxy `get` trap that throws", () => {
51+
const hostile = new Proxy(() => undefined, {
52+
get() {
53+
// oxlint-disable-next-line unthrown/no-throw -- test double: simulates a Proxy export with a throwing get trap
54+
throw new Error("hostile trap");
55+
},
56+
});
57+
expect(() => _internal_declaredWorkflowName(hostile)).not.toThrow();
58+
expect(_internal_declaredWorkflowName(hostile)).toBeUndefined();
59+
});
60+
});

packages/worker/src/workflow-brand.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,21 @@ export const DECLARED_WORKFLOW_BRAND = Symbol.for("temporal-contract.declareWork
2222
* with, or `undefined` for anything else. Used by `TypedWorker`'s
2323
* workflow-registration completeness check.
2424
*
25+
* The candidate is an arbitrary module export, so the property read itself is
26+
* untrusted: a `Proxy` with a throwing `get` trap, or a function carrying a
27+
* throwing getter, would otherwise abort the whole registration check with an
28+
* unrelated exception. Anything that fails to yield a plain string brand —
29+
* including by throwing — is simply "not a declared workflow".
30+
*
2531
* @internal
2632
*/
2733
export function _internal_declaredWorkflowName(candidate: unknown): string | undefined {
2834
if (typeof candidate !== "function") return undefined;
29-
const brand = (candidate as { [DECLARED_WORKFLOW_BRAND]?: unknown })[DECLARED_WORKFLOW_BRAND];
35+
let brand: unknown;
36+
try {
37+
brand = (candidate as { [DECLARED_WORKFLOW_BRAND]?: unknown })[DECLARED_WORKFLOW_BRAND];
38+
} catch {
39+
return undefined;
40+
}
3041
return typeof brand === "string" ? brand : undefined;
3142
}

packages/worker/vitest.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ export default defineConfig({
2424
include: ["src/**/__tests__/*.spec.ts"],
2525
exclude: ["src/**/__tests__/*.inprocess.spec.ts"],
2626
testTimeout: 10_000,
27+
// Fixture setup opens Temporal connections and builds a workflow
28+
// bundle per worker before the test body runs; Vitest's 10s hook
29+
// default is too tight for that on a loaded CI runner. The test
30+
// body itself is still bounded by `testTimeout` above.
31+
hookTimeout: 60_000,
2732
setupFiles: ["./src/vitest.setup.ts"],
2833
},
2934
},

0 commit comments

Comments
 (0)