Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/experimental-node-runtimes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---

Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to `experimental-node-24` or `experimental-node-26` in `trigger.config.ts`.
1 change: 1 addition & 0 deletions apps/supervisor/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ class ManagedSupervisor {
projectId: message.project.id,
deploymentFriendlyId: message.deployment.friendlyId,
deploymentVersion: message.backgroundWorker.version,
runtime: message.backgroundWorker.runtime,
runId: message.run.id,
runFriendlyId: message.run.friendlyId,
version: message.version,
Expand Down
52 changes: 52 additions & 0 deletions apps/supervisor/src/workloadManager/kubernetes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
BLOCK_IO_URING_SECCOMP_PROFILE,
runtimeRequiresSeccompProfile,
withBlockIoUringSeccompProfile,
} from "./kubernetesPodSpec.js";

describe("runtimeRequiresSeccompProfile", () => {
it("returns true for node-24 and above", () => {
expect(runtimeRequiresSeccompProfile("node-24")).toBe(true);
expect(runtimeRequiresSeccompProfile("node-26")).toBe(true);
expect(runtimeRequiresSeccompProfile("node-30")).toBe(true);
expect(runtimeRequiresSeccompProfile("experimental-node-24")).toBe(true);
});

it("returns false for runtimes that do not create io_uring fds", () => {
expect(runtimeRequiresSeccompProfile("node")).toBe(false);
expect(runtimeRequiresSeccompProfile("node-22")).toBe(false);
expect(runtimeRequiresSeccompProfile("bun")).toBe(false);
expect(runtimeRequiresSeccompProfile(undefined)).toBe(false);
expect(runtimeRequiresSeccompProfile(null)).toBe(false);
expect(runtimeRequiresSeccompProfile("")).toBe(false);
});
});

describe("withBlockIoUringSeccompProfile", () => {
it("adds the Localhost io_uring profile while preserving pod security defaults", () => {
const podSpec = withBlockIoUringSeccompProfile({
restartPolicy: "Never",
automountServiceAccountToken: false,
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
fsGroup: 1000,
},
});

expect(podSpec).toMatchObject({
restartPolicy: "Never",
automountServiceAccountToken: false,
securityContext: {
runAsNonRoot: true,
runAsUser: 1000,
fsGroup: 1000,
seccompProfile: {
type: "Localhost",
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
},
},
});
});
});
11 changes: 10 additions & 1 deletion apps/supervisor/src/workloadManager/kubernetes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly";
import { env } from "../env.js";
import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js";
import { getRunnerId } from "../util.js";
import {
runtimeRequiresSeccompProfile,
withBlockIoUringSeccompProfile,
} from "./kubernetesPodSpec.js";

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

try {
const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags);
const podSpec = runtimeRequiresSeccompProfile(opts.runtime)
? withBlockIoUringSeccompProfile(basePodSpec)
: basePodSpec;

await this.k8s.core.createNamespacedPod({
namespace: this.namespace,
body: {
Expand All @@ -119,7 +128,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
},
},
spec: {
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
...podSpec,
affinity: this.#getAffinity(opts),
tolerations: this.#getScheduleTolerations(this.#isScheduledRun(opts)),
terminationGracePeriodSeconds: 60 * 60,
Expand Down
46 changes: 46 additions & 0 deletions apps/supervisor/src/workloadManager/kubernetesPodSpec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import type { k8s } from "../clients/kubernetes.js";

/**
* Path (relative to the kubelet seccomp root, /var/lib/kubelet/seccomp) of the
* targeted seccomp profile that blocks only io_uring_setup/enter/register and
* allows every other syscall. Must match the profile distributed to worker nodes
* by the infra kubeadm config.
*/
export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json";

/**
* Node >= 24 (libuv >= ~1.52) unconditionally creates io_uring file descriptors,
* which cannot be checkpointed. Launching those pods under a seccomp profile that
* fails io_uring_setup makes libuv fall back to epoll, keeping the pod
* checkpointable. "node" (21.x), "node-22" (UV_USE_IO_URING=0 still works) and
* "bun" do not create these descriptors, so the profile is scoped to node-24+ to
* avoid changing the syscall surface of existing runtimes.
*
* Tolerant of an "experimental-" prefix in case a non-normalized value reaches here.
*/
export function runtimeRequiresSeccompProfile(runtime: string | null | undefined): boolean {
if (!runtime) return false;
const match = /^(?:experimental-)?node-(\d+)$/.exec(runtime);
return match ? Number(match[1]) >= 24 : false;
}

/**
* Applies the targeted Localhost profile that blocks only io_uring, preserving any
* existing security-context fields. Unlike RuntimeDefault this restricts no other
* syscalls, so it cannot break unrelated workloads (browsers, sandboxes, native
* threads). Only call this for runtimes where runtimeRequiresSeccompProfile is true.
*/
export function withBlockIoUringSeccompProfile(
podSpec: Omit<k8s.V1PodSpec, "containers">
): Omit<k8s.V1PodSpec, "containers"> {
return {
...podSpec,
securityContext: {
...podSpec.securityContext,
seccompProfile: {
type: "Localhost",
localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE,
},
},
};
}
3 changes: 3 additions & 0 deletions apps/supervisor/src/workloadManager/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ export interface WorkloadManagerCreateOptions {
projectId: string;
deploymentFriendlyId: string;
deploymentVersion: string;
// Canonical runtime identifier (e.g. "node", "node-22", "node-24"). Used to
// scope the io_uring-blocking seccomp profile to runtimes that require it.
runtime?: string;
runId: string;
runFriendlyId: string;
snapshotId: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,7 @@ export class DequeueSystem {
id: result.worker.id,
friendlyId: result.worker.friendlyId,
version: result.worker.version,
runtime: result.worker.runtime ?? undefined,
},
// TODO: use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments.
// Would help make the typechecking stricter
Expand Down
2 changes: 1 addition & 1 deletion packages/cli-v3/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Examples:
)
.option(
"-r, --runtime <runtime>",
"Which runtime to use for the project. Currently only supports node and bun",
"Which runtime to use for the project. Supported: node, node-22, bun",
"node"
)
.option("--skip-package-install", "Skip installing the @trigger.dev/sdk package")
Expand Down
60 changes: 60 additions & 0 deletions packages/cli-v3/src/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadConfig } from "./config.js";

const projectDirs: string[] = [];

afterEach(async () => {
await Promise.all(projectDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});

async function createProject(runtime?: string) {
const cwd = await mkdtemp(join(tmpdir(), "trigger-runtime-config-"));
projectDirs.push(cwd);

await mkdir(join(cwd, "trigger"));
await writeFile(join(cwd, "package.json"), JSON.stringify({ name: "runtime-config-test" }));
await writeFile(join(cwd, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n");
await writeFile(
join(cwd, "trigger.config.ts"),
`export default {
project: "proj_runtime_config_test",
maxDuration: 60,
dirs: ["./trigger"],
${runtime === undefined ? "" : `runtime: ${JSON.stringify(runtime)},`}
};
`
);

return cwd;
}

describe("loadConfig runtime", () => {
it.each([
["experimental-node-24", "node-24"],
["experimental-node-26", "node-26"],
] as const)("normalizes %s before returning the resolved config", async (runtime, expected) => {
const cwd = await createProject(runtime);

await expect(loadConfig({ cwd, warn: false })).resolves.toMatchObject({ runtime: expected });
});

it("keeps node as the default", async () => {
const cwd = await createProject();

await expect(loadConfig({ cwd, warn: false })).resolves.toMatchObject({ runtime: "node" });
});

it.each(["node-24", "node-26", "node-23"])(
"rejects unsupported public runtime %s while loading config",
async (runtime) => {
const cwd = await createProject(runtime);

await expect(loadConfig({ cwd, warn: false })).rejects.toThrowError(
new RegExp(`Unsupported runtime "${runtime}" in trigger\\.config`)
);
}
);
});
38 changes: 25 additions & 13 deletions packages/cli-v3/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ import type {
TriggerConfig,
} from "@trigger.dev/core/v3";
import type { ResolvedConfig } from "@trigger.dev/core/v3/build";
import { DEFAULT_RUNTIME } from "@trigger.dev/core/v3/build";
import {
DEFAULT_RUNTIME,
isExperimentalConfigRuntime,
resolveBuildRuntime,
} from "@trigger.dev/core/v3/build";
import * as c12 from "c12";
import { defu } from "defu";
import type * as esbuild from "esbuild";
Expand Down Expand Up @@ -170,6 +174,24 @@ async function resolveConfig(
);
}

const config =
"config" in result.config ? (result.config.config as TriggerConfig) : result.config;

const features = featuresFromCompatibilityFlags(
["run_engine_v2" as const].concat(config.compatibilityFlags ?? [])
);
const defaultRuntime: BuildRuntime = features.run_engine_v2 ? "node" : DEFAULT_RUNTIME;
const configuredRuntime = overrides?.runtime ?? config.runtime ?? defaultRuntime;
const runtime = resolveBuildRuntime(configuredRuntime);

if (warn && isExperimentalConfigRuntime(configuredRuntime)) {
prettyWarning(
`The "${configuredRuntime}" runtime is experimental and may change before general availability.`
);
}

validateConfig(config, warn);

const packageJsonPath = await resolvePackageJSON(cwd);
const tsconfigPath = await safeResolveTsConfig(cwd);
const lockfilePath = await resolveLockfile(cwd);
Expand All @@ -181,24 +203,14 @@ async function resolveConfig(
? dirname(packageJsonPath)
: cwd;

const config =
"config" in result.config ? (result.config.config as TriggerConfig) : result.config;

validateConfig(config, warn);

let dirs = config.dirs ? config.dirs : await autoDetectDirs(workingDir);

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

const features = featuresFromCompatibilityFlags(
["run_engine_v2" as const].concat(config.compatibilityFlags ?? [])
);

const defaultRuntime: BuildRuntime = features.run_engine_v2 ? "node" : DEFAULT_RUNTIME;

const mergedConfig = defu(
{
workingDir,
runtime,
configFile: result.configFile,
packageJsonPath,
tsconfigPath,
Expand Down Expand Up @@ -230,7 +242,7 @@ async function resolveConfig(
...mergedConfig,
dirs: Array.from(new Set(dirs)),
instrumentedPackageNames: getInstrumentedPackageNames(mergedConfig),
runtime: mergedConfig.runtime,
runtime,
};
}

Expand Down
28 changes: 28 additions & 0 deletions packages/cli-v3/src/deploy/buildImage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { BuildRuntime } from "@trigger.dev/core/v3/schemas";
import { describe, expect, it } from "vitest";
import { generateContainerfile } from "./buildImage.js";

const nodeImages: Array<[BuildRuntime, string]> = [
[
"node-24",
"node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d",
],
[
"node-26",
"node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1",
],
];

describe("generateContainerfile", () => {
it.each(nodeImages)("selects the pinned multiplatform image for %s", async (runtime, image) => {
const containerfile = await generateContainerfile({
runtime,
build: {},
image: undefined,
indexScript: "index.js",
entrypoint: "entrypoint.js",
});

expect(containerfile).toContain(`FROM ${image} AS base`);
});
});
8 changes: 7 additions & 1 deletion packages/cli-v3/src/deploy/buildImage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -692,14 +692,20 @@ const BASE_IMAGE: Record<BuildRuntime, string> = {
node: "node:21.7.3-bookworm-slim@sha256:dfc05dee209a1d7adf2ef189bd97396daad4e97c6eaa85778d6f75205ba1b0fb",
"node-22":
"node:22.16.0-bookworm-slim@sha256:048ed02c5fd52e86fda6fbd2f6a76cf0d4492fd6c6fee9e2c463ed5108da0e34",
"node-24":
"node:24.18.0-bookworm-slim@sha256:6f7b03f7c2c8e2e784dcf9295400527b9b1270fd37b7e9a7285cf83b6951452d",
"node-26":
"node:26.4.0-bookworm-slim@sha256:ec82d089a8ae2cf02628da7b34ea57dc357b24db724d557fe2d240e6beb659c1",
};

const DEFAULT_PACKAGES = ["busybox", "ca-certificates", "dumb-init", "git", "openssl"];

export async function generateContainerfile(options: GenerateContainerfileOptions) {
switch (options.runtime) {
case "node":
case "node-22": {
case "node-22":
case "node-24":
case "node-26": {
return await generateNodeContainerfile(options);
}
case "bun": {
Expand Down
Loading