Skip to content

Commit d7ec75d

Browse files
authored
feat(runtime): add experimental Node.js 24 and 26 task runtimes (#4085)
## Summary Adds experimental Node.js 24 and 26 task runtimes through the `experimental-node-24` and `experimental-node-26` config values. Existing runtime defaults and the `node`, `node-22`, and `bun` behavior remain unchanged. The unprefixed `node-24` and `node-26` config values remain unavailable until the runtimes are ready for general use. ## Design Experimental config values normalize to canonical runtime identifiers before build manifests are created, keeping deployment metadata and execution behavior consistent. Kubernetes task pods also use the runtime-default seccomp profile so modern Node.js versions fall back from io_uring to checkpoint-compatible system calls.
1 parent 4325052 commit d7ec75d

18 files changed

Lines changed: 337 additions & 39 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"trigger.dev": patch
4+
---
5+
6+
Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to `experimental-node-24` or `experimental-node-26` in `trigger.config.ts`.

apps/supervisor/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ class ManagedSupervisor {
120120
snapshotPollIntervalSeconds: env.RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS,
121121
additionalEnvVars: env.RUNNER_ADDITIONAL_ENV_VARS,
122122
dockerAutoremove: env.DOCKER_AUTOREMOVE_EXITED_CONTAINERS,
123+
checkpointsEnabled: !!env.TRIGGER_CHECKPOINT_URL,
123124
} satisfies WorkloadManagerOptions;
124125

125126
this.resourceMonitor = env.RESOURCE_MONITOR_ENABLED
@@ -615,6 +616,7 @@ class ManagedSupervisor {
615616
projectId: message.project.id,
616617
deploymentFriendlyId: message.deployment.friendlyId,
617618
deploymentVersion: message.backgroundWorker.version,
619+
runtime: message.backgroundWorker.runtime,
618620
runId: message.run.id,
619621
runFriendlyId: message.run.friendlyId,
620622
version: message.version,
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
BLOCK_IO_URING_SECCOMP_PROFILE,
4+
withBlockIoUringSeccompProfile,
5+
} from "./kubernetesPodSpec.js";
6+
7+
const basePodSpec = {
8+
restartPolicy: "Never" as const,
9+
automountServiceAccountToken: false,
10+
securityContext: {
11+
runAsNonRoot: true,
12+
runAsUser: 1000,
13+
fsGroup: 1000,
14+
},
15+
};
16+
17+
describe("withBlockIoUringSeccompProfile", () => {
18+
it("adds the Localhost io_uring profile for node-24 and above, preserving pod security defaults", () => {
19+
for (const runtime of ["node-24", "node-26", "node-30", "experimental-node-24"]) {
20+
const podSpec = withBlockIoUringSeccompProfile(basePodSpec, runtime);
21+
22+
expect(podSpec).toMatchObject({
23+
...basePodSpec,
24+
securityContext: {
25+
...basePodSpec.securityContext,
26+
seccompProfile: {
27+
type: "Localhost",
28+
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
29+
},
30+
},
31+
});
32+
}
33+
});
34+
35+
it("leaves the pod spec unchanged for runtimes that do not create io_uring fds", () => {
36+
for (const runtime of ["node", "node-22", "bun", undefined, null, ""]) {
37+
expect(withBlockIoUringSeccompProfile(basePodSpec, runtime)).toEqual(basePodSpec);
38+
}
39+
});
40+
});

apps/supervisor/src/workloadManager/kubernetes.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly";
1414
import { env } from "../env.js";
1515
import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js";
1616
import { getRunnerId } from "../util.js";
17+
import { withBlockIoUringSeccompProfile } from "./kubernetesPodSpec.js";
1718

1819
type ResourceQuantities = {
1920
[K in "cpu" | "memory" | "ephemeral-storage"]?: string;
@@ -105,6 +106,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
105106
const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber);
106107

107108
try {
109+
const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags);
110+
const podSpec = this.opts.checkpointsEnabled
111+
? withBlockIoUringSeccompProfile(basePodSpec, opts.runtime)
112+
: basePodSpec;
113+
108114
await this.k8s.core.createNamespacedPod({
109115
namespace: this.namespace,
110116
body: {
@@ -119,7 +125,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
119125
},
120126
},
121127
spec: {
122-
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
128+
...podSpec,
123129
affinity: this.#getAffinity(opts),
124130
tolerations: this.#getScheduleTolerations(this.#isScheduledRun(opts)),
125131
terminationGracePeriodSeconds: 60 * 60,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { k8s } from "../clients/kubernetes.js";
2+
3+
/**
4+
* Relative path (kubelet seccomp root) of the profile blocking only io_uring
5+
* syscalls. Must match the profile deployed to worker nodes.
6+
*/
7+
export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json";
8+
9+
/**
10+
* Node >= 24 always creates io_uring fds, which can't be checkpointed. Blocking
11+
* io_uring_setup makes libuv fall back to epoll. Other runtimes don't need this,
12+
* so the profile is only applied for node-24+. Tolerates an "experimental-" prefix.
13+
*/
14+
export function withBlockIoUringSeccompProfile(
15+
podSpec: Omit<k8s.V1PodSpec, "containers">,
16+
runtime: string | null | undefined
17+
): Omit<k8s.V1PodSpec, "containers"> {
18+
const match = runtime ? /^(?:experimental-)?node-(\d+)$/.exec(runtime) : null;
19+
if (!match || Number(match[1]) < 24) {
20+
return podSpec;
21+
}
22+
23+
return {
24+
...podSpec,
25+
securityContext: {
26+
...podSpec.securityContext,
27+
seccompProfile: {
28+
type: "Localhost",
29+
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
30+
},
31+
},
32+
};
33+
}

apps/supervisor/src/workloadManager/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export interface WorkloadManagerOptions {
1616
snapshotPollIntervalSeconds?: number;
1717
additionalEnvVars?: Record<string, string>;
1818
dockerAutoremove?: boolean;
19+
// Whether CRIU checkpoint/restore is enabled for this deployment
20+
checkpointsEnabled?: boolean;
1921
}
2022

2123
export interface WorkloadManager {
@@ -40,6 +42,8 @@ export interface WorkloadManagerCreateOptions {
4042
projectId: string;
4143
deploymentFriendlyId: string;
4244
deploymentVersion: string;
45+
// Canonical runtime identifier (e.g. "node", "node-22", "node-24")
46+
runtime?: string;
4347
runId: string;
4448
runFriendlyId: string;
4549
snapshotId: string;

internal-packages/run-engine/src/engine/systems/dequeueSystem.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,7 @@ export class DequeueSystem {
597597
id: result.worker.id,
598598
friendlyId: result.worker.friendlyId,
599599
version: result.worker.version,
600+
runtime: result.worker.runtime ?? undefined,
600601
},
601602
// TODO: use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
602603
// Would help make the typechecking stricter

packages/cli-v3/src/commands/init.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ Examples:
9494
)
9595
.option(
9696
"-r, --runtime <runtime>",
97-
"Which runtime to use for the project. Currently only supports node and bun",
97+
"Which runtime to use for the project. Supported: node, node-22, bun",
9898
"node"
9999
)
100100
.option("--skip-package-install", "Skip installing the @trigger.dev/sdk package")

packages/cli-v3/src/config.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, describe, expect, it } from "vitest";
5+
import { loadConfig } from "./config.js";
6+
7+
const projectDirs: string[] = [];
8+
9+
afterEach(async () => {
10+
await Promise.all(projectDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
11+
});
12+
13+
async function createProject(runtime?: string) {
14+
const cwd = await mkdtemp(join(tmpdir(), "trigger-runtime-config-"));
15+
projectDirs.push(cwd);
16+
17+
await mkdir(join(cwd, "trigger"));
18+
await writeFile(join(cwd, "package.json"), JSON.stringify({ name: "runtime-config-test" }));
19+
await writeFile(join(cwd, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
20+
await writeFile(
21+
join(cwd, "trigger.config.ts"),
22+
`export default {
23+
project: "proj_runtime_config_test",
24+
maxDuration: 60,
25+
dirs: ["./trigger"],
26+
${runtime === undefined ? "" : `runtime: ${JSON.stringify(runtime)},`}
27+
};
28+
`
29+
);
30+
31+
return cwd;
32+
}
33+
34+
describe("loadConfig runtime", () => {
35+
it.each([
36+
["experimental-node-24", "node-24"],
37+
["experimental-node-26", "node-26"],
38+
] as const)("normalizes %s before returning the resolved config", async (runtime, expected) => {
39+
const cwd = await createProject(runtime);
40+
41+
await expect(loadConfig({ cwd, warn: false })).resolves.toMatchObject({ runtime: expected });
42+
});
43+
44+
it("keeps node as the default", async () => {
45+
const cwd = await createProject();
46+
47+
await expect(loadConfig({ cwd, warn: false })).resolves.toMatchObject({ runtime: "node" });
48+
});
49+
50+
it.each(["node-24", "node-26", "node-23"])(
51+
"rejects unsupported public runtime %s while loading config",
52+
async (runtime) => {
53+
const cwd = await createProject(runtime);
54+
55+
await expect(loadConfig({ cwd, warn: false })).rejects.toThrowError(
56+
new RegExp(`Unsupported runtime "${runtime}" in trigger\\.config`)
57+
);
58+
}
59+
);
60+
});

packages/cli-v3/src/config.ts

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ import type {
66
TriggerConfig,
77
} from "@trigger.dev/core/v3";
88
import type { ResolvedConfig } from "@trigger.dev/core/v3/build";
9-
import { DEFAULT_RUNTIME } from "@trigger.dev/core/v3/build";
9+
import {
10+
DEFAULT_RUNTIME,
11+
isExperimentalConfigRuntime,
12+
resolveBuildRuntime,
13+
} from "@trigger.dev/core/v3/build";
1014
import * as c12 from "c12";
1115
import { defu } from "defu";
1216
import type * as esbuild from "esbuild";
@@ -170,6 +174,24 @@ async function resolveConfig(
170174
);
171175
}
172176

177+
const config =
178+
"config" in result.config ? (result.config.config as TriggerConfig) : result.config;
179+
180+
const features = featuresFromCompatibilityFlags(
181+
["run_engine_v2" as const].concat(config.compatibilityFlags ?? [])
182+
);
183+
const defaultRuntime: BuildRuntime = features.run_engine_v2 ? "node" : DEFAULT_RUNTIME;
184+
const configuredRuntime = overrides?.runtime ?? config.runtime ?? defaultRuntime;
185+
const runtime = resolveBuildRuntime(configuredRuntime);
186+
187+
if (warn && isExperimentalConfigRuntime(configuredRuntime)) {
188+
prettyWarning(
189+
`The "${configuredRuntime}" runtime is experimental and may change before general availability.`
190+
);
191+
}
192+
193+
validateConfig(config, warn);
194+
173195
const packageJsonPath = await resolvePackageJSON(cwd);
174196
const tsconfigPath = await safeResolveTsConfig(cwd);
175197
const lockfilePath = await resolveLockfile(cwd);
@@ -181,24 +203,14 @@ async function resolveConfig(
181203
? dirname(packageJsonPath)
182204
: cwd;
183205

184-
const config =
185-
"config" in result.config ? (result.config.config as TriggerConfig) : result.config;
186-
187-
validateConfig(config, warn);
188-
189206
let dirs = config.dirs ? config.dirs : await autoDetectDirs(workingDir);
190207

191208
dirs = dirs.map((dir) => resolveTriggerDir(dir, workingDir));
192209

193-
const features = featuresFromCompatibilityFlags(
194-
["run_engine_v2" as const].concat(config.compatibilityFlags ?? [])
195-
);
196-
197-
const defaultRuntime: BuildRuntime = features.run_engine_v2 ? "node" : DEFAULT_RUNTIME;
198-
199210
const mergedConfig = defu(
200211
{
201212
workingDir,
213+
runtime,
202214
configFile: result.configFile,
203215
packageJsonPath,
204216
tsconfigPath,
@@ -230,7 +242,7 @@ async function resolveConfig(
230242
...mergedConfig,
231243
dirs: Array.from(new Set(dirs)),
232244
instrumentedPackageNames: getInstrumentedPackageNames(mergedConfig),
233-
runtime: mergedConfig.runtime,
245+
runtime,
234246
};
235247
}
236248

0 commit comments

Comments
 (0)