Skip to content

Commit 04bcb95

Browse files
CopilotTooTallNate
andcommitted
Override timeout functions in workflow VM context to throw helpful errors
Co-authored-by: TooTallNate <71256+TooTallNate@users.noreply.github.com>
1 parent b46046c commit 04bcb95

File tree

4 files changed

+244
-0
lines changed

4 files changed

+244
-0
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@workflow/errors": patch
3+
"@workflow/core": patch
4+
---
5+
6+
Override setTimeout, setInterval, and related functions in workflow VM context to throw helpful errors suggesting to use `sleep` instead

packages/core/src/workflow.test.ts

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -947,6 +947,219 @@ describe('runWorkflow', () => {
947947
});
948948
});
949949

950+
describe('timeout functions', () => {
951+
it('should throw an error when calling setTimeout', async () => {
952+
const ops: Promise<any>[] = [];
953+
const workflowRun: WorkflowRun = {
954+
runId: 'test-run-123',
955+
workflowName: 'workflow',
956+
status: 'running',
957+
input: dehydrateWorkflowArguments([], ops),
958+
createdAt: new Date('2024-01-01T00:00:00.000Z'),
959+
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
960+
startedAt: new Date('2024-01-01T00:00:00.000Z'),
961+
deploymentId: 'test-deployment',
962+
};
963+
964+
const events: Event[] = [];
965+
966+
await expect(
967+
runWorkflow(
968+
`async function workflow() {
969+
setTimeout(() => {}, 1000);
970+
return 'done';
971+
}${getWorkflowTransformCode('workflow')}`,
972+
workflowRun,
973+
events
974+
)
975+
).rejects.toThrow(
976+
'Timeout functions like "setTimeout" and "setInterval" are not supported in workflow functions'
977+
);
978+
});
979+
980+
it('should throw an error when calling setInterval', async () => {
981+
const ops: Promise<any>[] = [];
982+
const workflowRun: WorkflowRun = {
983+
runId: 'test-run-123',
984+
workflowName: 'workflow',
985+
status: 'running',
986+
input: dehydrateWorkflowArguments([], ops),
987+
createdAt: new Date('2024-01-01T00:00:00.000Z'),
988+
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
989+
startedAt: new Date('2024-01-01T00:00:00.000Z'),
990+
deploymentId: 'test-deployment',
991+
};
992+
993+
const events: Event[] = [];
994+
995+
await expect(
996+
runWorkflow(
997+
`async function workflow() {
998+
setInterval(() => {}, 1000);
999+
return 'done';
1000+
}${getWorkflowTransformCode('workflow')}`,
1001+
workflowRun,
1002+
events
1003+
)
1004+
).rejects.toThrow(
1005+
'Timeout functions like "setTimeout" and "setInterval" are not supported in workflow functions'
1006+
);
1007+
});
1008+
1009+
it('should throw an error when calling clearTimeout', async () => {
1010+
const ops: Promise<any>[] = [];
1011+
const workflowRun: WorkflowRun = {
1012+
runId: 'test-run-123',
1013+
workflowName: 'workflow',
1014+
status: 'running',
1015+
input: dehydrateWorkflowArguments([], ops),
1016+
createdAt: new Date('2024-01-01T00:00:00.000Z'),
1017+
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
1018+
startedAt: new Date('2024-01-01T00:00:00.000Z'),
1019+
deploymentId: 'test-deployment',
1020+
};
1021+
1022+
const events: Event[] = [];
1023+
1024+
await expect(
1025+
runWorkflow(
1026+
`async function workflow() {
1027+
clearTimeout(123);
1028+
return 'done';
1029+
}${getWorkflowTransformCode('workflow')}`,
1030+
workflowRun,
1031+
events
1032+
)
1033+
).rejects.toThrow(
1034+
'Timeout functions like "setTimeout" and "setInterval" are not supported in workflow functions'
1035+
);
1036+
});
1037+
1038+
it('should throw an error when calling clearInterval', async () => {
1039+
const ops: Promise<any>[] = [];
1040+
const workflowRun: WorkflowRun = {
1041+
runId: 'test-run-123',
1042+
workflowName: 'workflow',
1043+
status: 'running',
1044+
input: dehydrateWorkflowArguments([], ops),
1045+
createdAt: new Date('2024-01-01T00:00:00.000Z'),
1046+
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
1047+
startedAt: new Date('2024-01-01T00:00:00.000Z'),
1048+
deploymentId: 'test-deployment',
1049+
};
1050+
1051+
const events: Event[] = [];
1052+
1053+
await expect(
1054+
runWorkflow(
1055+
`async function workflow() {
1056+
clearInterval(123);
1057+
return 'done';
1058+
}${getWorkflowTransformCode('workflow')}`,
1059+
workflowRun,
1060+
events
1061+
)
1062+
).rejects.toThrow(
1063+
'Timeout functions like "setTimeout" and "setInterval" are not supported in workflow functions'
1064+
);
1065+
});
1066+
1067+
it('should throw an error when calling setImmediate', async () => {
1068+
const ops: Promise<any>[] = [];
1069+
const workflowRun: WorkflowRun = {
1070+
runId: 'test-run-123',
1071+
workflowName: 'workflow',
1072+
status: 'running',
1073+
input: dehydrateWorkflowArguments([], ops),
1074+
createdAt: new Date('2024-01-01T00:00:00.000Z'),
1075+
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
1076+
startedAt: new Date('2024-01-01T00:00:00.000Z'),
1077+
deploymentId: 'test-deployment',
1078+
};
1079+
1080+
const events: Event[] = [];
1081+
1082+
await expect(
1083+
runWorkflow(
1084+
`async function workflow() {
1085+
setImmediate(() => {});
1086+
return 'done';
1087+
}${getWorkflowTransformCode('workflow')}`,
1088+
workflowRun,
1089+
events
1090+
)
1091+
).rejects.toThrow(
1092+
'Timeout functions like "setTimeout" and "setInterval" are not supported in workflow functions'
1093+
);
1094+
});
1095+
1096+
it('should throw an error when calling clearImmediate', async () => {
1097+
const ops: Promise<any>[] = [];
1098+
const workflowRun: WorkflowRun = {
1099+
runId: 'test-run-123',
1100+
workflowName: 'workflow',
1101+
status: 'running',
1102+
input: dehydrateWorkflowArguments([], ops),
1103+
createdAt: new Date('2024-01-01T00:00:00.000Z'),
1104+
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
1105+
startedAt: new Date('2024-01-01T00:00:00.000Z'),
1106+
deploymentId: 'test-deployment',
1107+
};
1108+
1109+
const events: Event[] = [];
1110+
1111+
await expect(
1112+
runWorkflow(
1113+
`async function workflow() {
1114+
clearImmediate(123);
1115+
return 'done';
1116+
}${getWorkflowTransformCode('workflow')}`,
1117+
workflowRun,
1118+
events
1119+
)
1120+
).rejects.toThrow(
1121+
'Timeout functions like "setTimeout" and "setInterval" are not supported in workflow functions'
1122+
);
1123+
});
1124+
1125+
it('should include documentation link in error message', async () => {
1126+
let error: Error | undefined;
1127+
try {
1128+
const ops: Promise<any>[] = [];
1129+
const workflowRun: WorkflowRun = {
1130+
runId: 'test-run-123',
1131+
workflowName: 'workflow',
1132+
status: 'running',
1133+
input: dehydrateWorkflowArguments([], ops),
1134+
createdAt: new Date('2024-01-01T00:00:00.000Z'),
1135+
updatedAt: new Date('2024-01-01T00:00:00.000Z'),
1136+
startedAt: new Date('2024-01-01T00:00:00.000Z'),
1137+
deploymentId: 'test-deployment',
1138+
};
1139+
1140+
const events: Event[] = [];
1141+
1142+
await runWorkflow(
1143+
`async function workflow() {
1144+
setTimeout(() => {}, 1000);
1145+
return 'done';
1146+
}${getWorkflowTransformCode('workflow')}`,
1147+
workflowRun,
1148+
events
1149+
);
1150+
} catch (err) {
1151+
error = err as Error;
1152+
}
1153+
assert(error);
1154+
expect(error.message).toContain(
1155+
'https://useworkflow.dev/err/timeout-in-workflow'
1156+
);
1157+
expect(error.message).toContain(
1158+
'Use the "sleep" function from "workflow"'
1159+
);
1160+
});
1161+
});
1162+
9501163
describe('hook', () => {
9511164
it('should throw `WorkflowSuspension` when a hook is awaiting without a "hook_received" event', async () => {
9521165
let error: Error | undefined;

packages/core/src/workflow.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,30 @@ export async function runWorkflow(
127127
);
128128
};
129129

130+
// Override timeout/interval functions to throw helpful errors
131+
// These are not supported in workflow functions because they rely on
132+
// asynchronous scheduling which breaks deterministic replay
133+
const timeoutErrorMessage = `Timeout functions like "setTimeout" and "setInterval" are not supported in workflow functions. Use the "sleep" function from "workflow" for time-based delays.\n\nLearn more: https://useworkflow.dev/err/${ERROR_SLUGS.TIMEOUT_FUNCTIONS_IN_WORKFLOW}`;
134+
135+
(vmGlobalThis as any).setTimeout = () => {
136+
throw new vmGlobalThis.Error(timeoutErrorMessage);
137+
};
138+
(vmGlobalThis as any).setInterval = () => {
139+
throw new vmGlobalThis.Error(timeoutErrorMessage);
140+
};
141+
(vmGlobalThis as any).clearTimeout = () => {
142+
throw new vmGlobalThis.Error(timeoutErrorMessage);
143+
};
144+
(vmGlobalThis as any).clearInterval = () => {
145+
throw new vmGlobalThis.Error(timeoutErrorMessage);
146+
};
147+
(vmGlobalThis as any).setImmediate = () => {
148+
throw new vmGlobalThis.Error(timeoutErrorMessage);
149+
};
150+
(vmGlobalThis as any).clearImmediate = () => {
151+
throw new vmGlobalThis.Error(timeoutErrorMessage);
152+
};
153+
130154
// `Request` and `Response` are special built-in classes that invoke steps
131155
// for the `json()`, `text()` and `arrayBuffer()` instance methods
132156
class Request implements globalThis.Request {

packages/errors/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export const ERROR_SLUGS = {
3030
WEBHOOK_INVALID_RESPOND_WITH_VALUE: 'webhook-invalid-respond-with-value',
3131
WEBHOOK_RESPONSE_NOT_SENT: 'webhook-response-not-sent',
3232
FETCH_IN_WORKFLOW_FUNCTION: 'fetch-in-workflow',
33+
TIMEOUT_FUNCTIONS_IN_WORKFLOW: 'timeout-in-workflow',
3334
} as const;
3435

3536
type ErrorSlug = (typeof ERROR_SLUGS)[keyof typeof ERROR_SLUGS];

0 commit comments

Comments
 (0)