first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -0,0 +1,34 @@
from dataclasses import dataclass
from src.env import read_int_env, read_bool_env, read_str_env
from src.errors import ConfigurationError
from src.constants import (
DEFAULT_HEALTH_CHECK_SERVER_HOST,
DEFAULT_HEALTH_CHECK_SERVER_PORT,
ENV_HEALTH_CHECK_SERVER_ENABLED,
ENV_HEALTH_CHECK_SERVER_HOST,
ENV_HEALTH_CHECK_SERVER_PORT,
)
@dataclass
class HealthCheckConfig:
enabled: bool
host: str
port: int
@classmethod
def from_env(cls):
port = read_int_env(
ENV_HEALTH_CHECK_SERVER_PORT, DEFAULT_HEALTH_CHECK_SERVER_PORT
)
if port < 0 or port > 65535:
raise ConfigurationError(f"Port must be between 0 and 65535, got {port}")
return cls(
enabled=read_bool_env(ENV_HEALTH_CHECK_SERVER_ENABLED, default=False),
host=read_str_env(
ENV_HEALTH_CHECK_SERVER_HOST, DEFAULT_HEALTH_CHECK_SERVER_HOST
),
port=port,
)
@@ -0,0 +1,9 @@
from dataclasses import dataclass
@dataclass
class SecurityConfig:
stdlib_allow: set[str]
external_allow: set[str]
builtins_deny: set[str]
runner_env_deny: bool
@@ -0,0 +1,36 @@
from dataclasses import dataclass
from src.env import read_str_env, read_float_env
from src.constants import (
ENV_DEPLOYMENT_NAME,
ENV_ENVIRONMENT,
ENV_N8N_VERSION,
ENV_SENTRY_DSN,
ENV_SENTRY_PROFILES_SAMPLE_RATE,
ENV_SENTRY_TRACES_SAMPLE_RATE,
)
@dataclass
class SentryConfig:
dsn: str
n8n_version: str
environment: str
deployment_name: str
profiles_sample_rate: float
traces_sample_rate: float
@property
def enabled(self) -> bool:
return bool(self.dsn)
@classmethod
def from_env(cls):
return cls(
dsn=read_str_env(ENV_SENTRY_DSN, ""),
n8n_version=read_str_env(ENV_N8N_VERSION, ""),
environment=read_str_env(ENV_ENVIRONMENT, ""),
deployment_name=read_str_env(ENV_DEPLOYMENT_NAME, ""),
profiles_sample_rate=read_float_env(ENV_SENTRY_PROFILES_SAMPLE_RATE, 0),
traces_sample_rate=read_float_env(ENV_SENTRY_TRACES_SAMPLE_RATE, 0),
)
@@ -0,0 +1,122 @@
from dataclasses import dataclass
from src.env import read_bool_env, read_int_env, read_str_env
from src.errors import ConfigurationError
from src.constants import (
BUILTINS_DENY_DEFAULT,
DEFAULT_MAX_CONCURRENCY,
DEFAULT_MAX_PAYLOAD_SIZE,
DEFAULT_TASK_BROKER_URI,
DEFAULT_TASK_TIMEOUT,
DEFAULT_AUTO_SHUTDOWN_TIMEOUT,
DEFAULT_SHUTDOWN_TIMEOUT,
ENV_BLOCK_RUNNER_ENV_ACCESS,
ENV_BUILTINS_DENY,
ENV_EXTERNAL_ALLOW,
ENV_GRANT_TOKEN,
ENV_MAX_CONCURRENCY,
ENV_MAX_PAYLOAD_SIZE,
ENV_STDLIB_ALLOW,
ENV_TASK_BROKER_URI,
ENV_TASK_TIMEOUT,
ENV_AUTO_SHUTDOWN_TIMEOUT,
ENV_GRACEFUL_SHUTDOWN_TIMEOUT,
PIPE_MSG_MAX_SIZE,
)
def parse_allowlist(allowlist_str: str, list_name: str) -> set[str]:
if not allowlist_str:
return set()
modules = {
module
for raw_module in allowlist_str.split(",")
if (module := raw_module.strip())
}
if "*" in modules and len(modules) > 1:
raise ConfigurationError(
f"Wildcard '*' in {list_name} must be used alone, not with other modules. "
f"Got: {', '.join(sorted(modules))}"
)
return modules
@dataclass
class TaskRunnerConfig:
grant_token: str
task_broker_uri: str
max_concurrency: int
max_payload_size: int
task_timeout: int
auto_shutdown_timeout: int
graceful_shutdown_timeout: int
stdlib_allow: set[str]
external_allow: set[str]
builtins_deny: set[str]
env_deny: bool
@property
def is_auto_shutdown_enabled(self) -> bool:
return self.auto_shutdown_timeout > 0
@classmethod
def from_env(cls):
grant_token = read_str_env(ENV_GRANT_TOKEN, "")
if not grant_token:
raise ConfigurationError(
"Environment variable N8N_RUNNERS_GRANT_TOKEN is required"
)
task_timeout = read_int_env(ENV_TASK_TIMEOUT, DEFAULT_TASK_TIMEOUT)
if task_timeout <= 0:
raise ConfigurationError(
f"Task timeout must be positive, got {task_timeout}"
)
auto_shutdown_timeout = read_int_env(
ENV_AUTO_SHUTDOWN_TIMEOUT, DEFAULT_AUTO_SHUTDOWN_TIMEOUT
)
if auto_shutdown_timeout < 0:
raise ConfigurationError(
f"Auto shutdown timeout must be non-negative, got {auto_shutdown_timeout}"
)
graceful_shutdown_timeout = read_int_env(
ENV_GRACEFUL_SHUTDOWN_TIMEOUT, DEFAULT_SHUTDOWN_TIMEOUT
)
if graceful_shutdown_timeout <= 0:
raise ConfigurationError(
f"Graceful shutdown timeout must be positive, got {graceful_shutdown_timeout}"
)
max_payload_size = read_int_env(ENV_MAX_PAYLOAD_SIZE, DEFAULT_MAX_PAYLOAD_SIZE)
if max_payload_size > PIPE_MSG_MAX_SIZE:
raise ConfigurationError(
f"Max payload size of {max_payload_size} bytes exceeds pipe message limit of {PIPE_MSG_MAX_SIZE} bytes. Reduce {ENV_MAX_PAYLOAD_SIZE}."
)
return cls(
grant_token=grant_token,
task_broker_uri=read_str_env(ENV_TASK_BROKER_URI, DEFAULT_TASK_BROKER_URI),
max_concurrency=read_int_env(ENV_MAX_CONCURRENCY, DEFAULT_MAX_CONCURRENCY),
max_payload_size=max_payload_size,
task_timeout=task_timeout,
auto_shutdown_timeout=auto_shutdown_timeout,
graceful_shutdown_timeout=graceful_shutdown_timeout,
stdlib_allow=parse_allowlist(
read_str_env(ENV_STDLIB_ALLOW, ""), ENV_STDLIB_ALLOW
),
external_allow=parse_allowlist(
read_str_env(ENV_EXTERNAL_ALLOW, ""), ENV_EXTERNAL_ALLOW
),
builtins_deny=set(
module.strip()
for module in read_str_env(
ENV_BUILTINS_DENY, BUILTINS_DENY_DEFAULT
).split(",")
),
env_deny=read_bool_env(ENV_BLOCK_RUNNER_ENV_ACCESS, True),
)
@@ -0,0 +1,190 @@
from src.errors import (
ConfigurationError,
TaskCancelledError,
TaskRuntimeError,
TaskTimeoutError,
SecurityViolationError,
WebsocketConnectionError,
)
# Messages
BROKER_INFO_REQUEST = "broker:inforequest"
BROKER_RUNNER_REGISTERED = "broker:runnerregistered"
BROKER_TASK_OFFER_ACCEPT = "broker:taskofferaccept"
BROKER_TASK_SETTINGS = "broker:tasksettings"
BROKER_TASK_CANCEL = "broker:taskcancel"
BROKER_RPC_RESPONSE = "broker:rpcresponse"
BROKER_DRAIN = "broker:drain"
RUNNER_INFO = "runner:info"
RUNNER_TASK_OFFER = "runner:taskoffer"
RUNNER_TASK_ACCEPTED = "runner:taskaccepted"
RUNNER_TASK_REJECTED = "runner:taskrejected"
RUNNER_TASK_DONE = "runner:taskdone"
RUNNER_TASK_ERROR = "runner:taskerror"
RUNNER_RPC_CALL = "runner:rpc"
# Runner
TASK_TYPE_PYTHON = "python"
RUNNER_NAME = "Python Task Runner"
DEFAULT_MAX_CONCURRENCY = 5 # tasks
DEFAULT_MAX_PAYLOAD_SIZE = 1024 * 1024 * 1024 # 1 GiB
DEFAULT_TASK_TIMEOUT = 60 # seconds
DEFAULT_AUTO_SHUTDOWN_TIMEOUT = 0 # seconds
DEFAULT_SHUTDOWN_TIMEOUT = 10 # seconds
OFFER_INTERVAL = 0.25 # 250ms
OFFER_VALIDITY = 5000 # ms
OFFER_VALIDITY_MAX_JITTER = 500 # ms
OFFER_VALIDITY_LATENCY_BUFFER = 0.1 # 100ms
MAX_VALIDATION_CACHE_SIZE = 500 # cached validation results
# Executor
EXECUTOR_USER_OUTPUT_KEY = "__n8n_internal_user_output__"
EXECUTOR_CIRCULAR_REFERENCE_KEY = "__n8n_internal_circular_ref__"
EXECUTOR_ALL_ITEMS_FILENAME = "<all_items_task_execution>"
EXECUTOR_PER_ITEM_FILENAME = "<per_item_task_execution>"
EXECUTOR_FILENAMES = {EXECUTOR_ALL_ITEMS_FILENAME, EXECUTOR_PER_ITEM_FILENAME}
SIGTERM_EXIT_CODE = -15
SIGKILL_EXIT_CODE = -9
PIPE_MSG_PREFIX_LENGTH = 4 # bytes
PIPE_MSG_MAX_SIZE = (
2 ** (PIPE_MSG_PREFIX_LENGTH * 8) - 1
) # bytes (~4 GiB with 4-byte prefix)
# Broker
DEFAULT_TASK_BROKER_URI = "http://127.0.0.1:5679"
TASK_BROKER_WS_PATH = "/runners/_ws"
# Health check
DEFAULT_HEALTH_CHECK_SERVER_HOST = "127.0.0.1"
DEFAULT_HEALTH_CHECK_SERVER_PORT = 5681
# Env vars
ENV_TASK_BROKER_URI = "N8N_RUNNERS_TASK_BROKER_URI"
ENV_GRANT_TOKEN = "N8N_RUNNERS_GRANT_TOKEN"
ENV_MAX_CONCURRENCY = "N8N_RUNNERS_MAX_CONCURRENCY"
ENV_MAX_PAYLOAD_SIZE = "N8N_RUNNERS_MAX_PAYLOAD"
ENV_TASK_TIMEOUT = "N8N_RUNNERS_TASK_TIMEOUT"
ENV_AUTO_SHUTDOWN_TIMEOUT = "N8N_RUNNERS_AUTO_SHUTDOWN_TIMEOUT"
ENV_GRACEFUL_SHUTDOWN_TIMEOUT = "N8N_RUNNERS_GRACEFUL_SHUTDOWN_TIMEOUT"
ENV_STDLIB_ALLOW = "N8N_RUNNERS_STDLIB_ALLOW"
ENV_EXTERNAL_ALLOW = "N8N_RUNNERS_EXTERNAL_ALLOW"
ENV_BUILTINS_DENY = "N8N_RUNNERS_BUILTINS_DENY"
ENV_HEALTH_CHECK_SERVER_ENABLED = "N8N_RUNNERS_HEALTH_CHECK_SERVER_ENABLED"
ENV_HEALTH_CHECK_SERVER_HOST = "N8N_RUNNERS_HEALTH_CHECK_SERVER_HOST"
ENV_HEALTH_CHECK_SERVER_PORT = "N8N_RUNNERS_HEALTH_CHECK_SERVER_PORT"
ENV_LAUNCHER_LOG_LEVEL = "N8N_RUNNERS_LAUNCHER_LOG_LEVEL"
ENV_BLOCK_RUNNER_ENV_ACCESS = "N8N_BLOCK_RUNNER_ENV_ACCESS"
ENV_SENTRY_DSN = "N8N_SENTRY_DSN"
ENV_N8N_VERSION = "N8N_VERSION"
ENV_ENVIRONMENT = "ENVIRONMENT"
ENV_DEPLOYMENT_NAME = "DEPLOYMENT_NAME"
ENV_SENTRY_PROFILES_SAMPLE_RATE = "N8N_SENTRY_PROFILES_SAMPLE_RATE"
ENV_SENTRY_TRACES_SAMPLE_RATE = "N8N_SENTRY_TRACES_SAMPLE_RATE"
# Sentry
SENTRY_TAG_SERVER_TYPE_KEY = "server_type"
SENTRY_TAG_SERVER_TYPE_VALUE = "task_runner_python"
IGNORED_ERROR_TYPES = (
ConfigurationError,
TaskRuntimeError,
TaskCancelledError,
TaskTimeoutError,
SecurityViolationError,
WebsocketConnectionError,
SyntaxError,
)
# Logging
LOG_FORMAT = "%(asctime)s.%(msecs)03d\t%(levelname)s\t%(message)s"
LOG_TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S"
LOG_TASK_COMPLETE = 'Completed task {task_id} in {duration} ({result_size}) for node "{node_name}" ({node_id}) in workflow "{workflow_name}" ({workflow_id})'
LOG_TASK_CANCEL = 'Cancelled task {task_id} for node "{node_name}" ({node_id}) in workflow "{workflow_name}" ({workflow_id})'
LOG_TASK_CANCEL_UNKNOWN = (
"Received cancel for unknown task: {task_id}. Discarding message."
)
LOG_TASK_CANCEL_WAITING = "Cancelled task {task_id} (waiting for settings)"
LOG_SENTRY_MISSING = "Sentry is enabled but sentry-sdk is not installed. Install with: uv sync --all-extras"
# RPC
RPC_BROWSER_CONSOLE_LOG_METHOD = "logNodeOutput"
# Rejection reasons
TASK_REJECTED_REASON_OFFER_EXPIRED = (
"Offer expired - not accepted within validity window"
)
TASK_REJECTED_REASON_AT_CAPACITY = "No open task slots - runner already at capacity"
# Security
BUILTINS_DENY_DEFAULT = "eval,exec,compile,open,input,breakpoint,getattr,object,type,vars,setattr,delattr,hasattr,dir,memoryview,__build_class__,globals,locals,license,help,credits,copyright"
BLOCKED_NAMES = {
"__loader__",
"__builtins__",
"__globals__",
"__spec__",
"__name__",
}
BLOCKED_ATTRIBUTES = {
# runtime attributes
"__subclasses__",
"__globals__",
"__builtins__",
"__traceback__",
"tb_frame",
"tb_next",
"f_back",
"f_globals",
"f_locals",
"f_code",
"f_builtins",
"__getattribute__",
"__qualname__",
"__module__",
"gi_frame",
"gi_code",
"gi_yieldfrom",
"cr_frame",
"cr_code",
"ag_frame",
"ag_code",
"obj",
"__thisclass__",
"__self_class__",
"__objclass__",
# introspection attributes
"__base__",
"__class__",
"__bases__",
"__code__",
"__closure__",
"__loader__",
"__cached__",
"__dict__",
"__import__",
"__mro__",
"__init_subclass__",
"__getattr__",
"__setattr__",
"__delattr__",
"__self__",
"__func__",
"__wrapped__",
"__annotations__",
"__spec__",
}
# errors
ERROR_RELATIVE_IMPORT = "Relative imports are disallowed."
ERROR_STDLIB_DISALLOWED = "Import of standard library module '{module}' is disallowed. Allowed stdlib modules: {allowed}"
ERROR_EXTERNAL_DISALLOWED = "Import of external package '{module}' is disallowed. Allowed external packages: {allowed}"
ERROR_DANGEROUS_NAME = "Access to name '{name}' is disallowed, because it can be used to bypass security restrictions."
ERROR_DANGEROUS_ATTRIBUTE = "Access to attribute '{attr}' is disallowed, because it can be used to bypass security restrictions."
ERROR_DANGEROUS_STRING_PATTERN = "String pattern accessing '{attr}' is disallowed, because it can be used to bypass security restrictions."
ERROR_NAME_MANGLED_ATTRIBUTE = "Access to name-mangled attributes (pattern: _ClassName__attr) is disallowed for security reasons."
ERROR_DYNAMIC_IMPORT = (
"Dynamic __import__() calls are not allowed for security reasons."
)
ERROR_MATCH_PATTERN_ATTRIBUTE = "Match pattern extracting attribute '{attr}' is disallowed, because it can be used to bypass security restrictions."
ERROR_WINDOWS_NOT_SUPPORTED = (
"Error: This task runner is not supported on Windows. "
"Please use a Unix-like system (Linux or macOS)."
)
@@ -0,0 +1,57 @@
import os
from pathlib import Path
def read_env(env_name: str) -> str | None:
if env_name in os.environ:
return os.environ[env_name]
file_path_key = f"{env_name}_FILE"
if file_path_key in os.environ:
file_path = os.environ[file_path_key]
try:
return Path(file_path).read_text(encoding="utf-8").strip()
except (OSError, IOError) as e:
raise ValueError(
f"Failed to read {env_name}_FILE from file {file_path}: {e}"
)
return None
def read_str_env(env_name: str, default: str) -> str:
value = read_env(env_name)
if value is None:
return default
return value
def read_int_env(env_name: str, default: int) -> int:
value = read_env(env_name)
if value is None:
return default
try:
return int(value)
except ValueError:
raise ValueError(
f"Environment variable {env_name} must be an integer, got '{value}'"
)
def read_bool_env(env_name: str, default: bool) -> bool:
value = read_env(env_name)
if value is None:
return default
return value.strip().lower() == "true"
def read_float_env(env_name: str, default: float) -> float:
value = read_env(env_name)
if value is None:
return default
try:
return float(value)
except ValueError:
raise ValueError(
f"Environment variable {env_name} must be a float, got '{value}'"
)
@@ -0,0 +1,31 @@
from .configuration_error import ConfigurationError
from .invalid_pipe_msg_content_error import InvalidPipeMsgContentError
from .invalid_pipe_msg_length_error import InvalidPipeMsgLengthError
from .no_idle_timeout_handler_error import NoIdleTimeoutHandlerError
from .security_violation_error import SecurityViolationError
from .task_cancelled_error import TaskCancelledError
from .task_killed_error import TaskKilledError
from .task_missing_error import TaskMissingError
from .task_result_missing_error import TaskResultMissingError
from .task_result_read_error import TaskResultReadError
from .task_subprocess_failed_error import TaskSubprocessFailedError
from .task_runtime_error import TaskRuntimeError
from .task_timeout_error import TaskTimeoutError
from .websocket_connection_error import WebsocketConnectionError
__all__ = [
"ConfigurationError",
"InvalidPipeMsgContentError",
"InvalidPipeMsgLengthError",
"NoIdleTimeoutHandlerError",
"SecurityViolationError",
"TaskCancelledError",
"TaskKilledError",
"TaskMissingError",
"TaskSubprocessFailedError",
"TaskResultMissingError",
"TaskResultReadError",
"TaskRuntimeError",
"TaskTimeoutError",
"WebsocketConnectionError",
]
@@ -0,0 +1,6 @@
class ConfigurationError(Exception):
"""Raised when runner configuration set by the user is invalid."""
def __init__(self, message: str = "Configuration error"):
super().__init__(message)
self.message = message
@@ -0,0 +1,3 @@
class InvalidPipeMsgContentError(Exception):
def __init__(self, message: str):
super().__init__(f"Invalid pipe message content: {message}")
@@ -0,0 +1,3 @@
class InvalidPipeMsgLengthError(Exception):
def __init__(self, length: int):
super().__init__(f"Invalid pipe message length: {length} bytes")
@@ -0,0 +1,9 @@
class NoIdleTimeoutHandlerError(Exception):
"""Raised when idle timeout is reached but no shutdown handler is configured."""
def __init__(self, timeout: int):
super().__init__(
f"Idle timeout is configured ({timeout}s) but no handler is set. "
"Set task_runner.on_idle_timeout before calling task_runner.start(). "
"This is an internal error."
)
@@ -0,0 +1,9 @@
class SecurityViolationError(Exception):
"""Raised when code violates security policies, typically through the use of disallowed modules or builtins."""
def __init__(
self, message: str = "Security violations detected", description: str = ""
):
super().__init__(message)
self.message = message
self.description = description
@@ -0,0 +1,5 @@
class TaskCancelledError(Exception):
"""Raised when a task is cancelled by broker message, by runner shutdown, etc."""
def __init__(self):
super().__init__("Task was cancelled")
@@ -0,0 +1,11 @@
class TaskKilledError(Exception):
"""Raised when a task process is forcefully killed (SIGKILL).
This usually indicates:
- Out of memory (OOM killer)
- Process exceeded resource limits
- Manual operator intervention
"""
def __init__(self):
super().__init__("Process was forcefully killed (SIGKILL)")
@@ -0,0 +1,12 @@
class TaskMissingError(Exception):
"""Raised when attempting to operate on a task that does not exist.
This typically indicates an internal error where the task runner
received a message referencing a task ID that is not currently
being tracked in the runner's running tasks.
"""
def __init__(self, task_id: str):
super().__init__(
f"Failed to find task {task_id}. This is likely an internal error."
)
@@ -0,0 +1,11 @@
class TaskResultMissingError(Exception):
"""Raised when a task subprocess exits successfully but returns no result.
This typically indicates an internal error where the subprocess did not
put any data in the communication queue.
"""
def __init__(self):
super().__init__(
"Process completed but returned no result. This is likely an internal error."
)
@@ -0,0 +1,4 @@
class TaskResultReadError(Exception):
def __init__(self, original_error: Exception | None = None):
super().__init__("Failed to read result from child process")
self.original_error = original_error
@@ -0,0 +1,16 @@
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.message_types.pipe import TaskErrorInfo
class TaskRuntimeError(Exception):
"""Raised when user code raises an exception during task execution."""
def __init__(self, task_error_info: "TaskErrorInfo"):
message = task_error_info["message"]
super().__init__(message)
self.stack_trace = task_error_info.get("stack", "")
self.description = task_error_info.get(
"description", ""
) or task_error_info.get("stderr", "")
@@ -0,0 +1,7 @@
class TaskSubprocessFailedError(Exception):
"""Raised when a task subprocess exits with a non-zero exit code, excluding SIGTERM and SIGKILL."""
def __init__(self, exit_code: int, original_error: Exception | None = None):
super().__init__(f"Task subprocess exited with code {exit_code}")
self.exit_code = exit_code
self.original_error = original_error
@@ -0,0 +1,7 @@
class TaskTimeoutError(Exception):
def __init__(self, task_timeout: int):
"""Raised when a task execution takes longer than the timeout limit."""
message = f"Task execution timed out after {task_timeout} {'second' if task_timeout == 1 else 'seconds'}"
super().__init__(message)
self.task_timeout = task_timeout
@@ -0,0 +1,10 @@
class WebsocketConnectionError(ConnectionError):
"""Raised when the task runner fails to establish a WebSocket connection to the broker.
Common causes include network issues, incorrect broker URI, or the broker service being unavailable.
"""
def __init__(self, broker_uri: str):
super().__init__(
f"Failed to connect to broker. Please check if broker is reachable at: {broker_uri}"
)
@@ -0,0 +1,50 @@
import asyncio
import errno
import logging
from src.config.health_check_config import HealthCheckConfig
HEALTH_CHECK_RESPONSE = (
b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 2\r\n\r\nOK"
)
class HealthCheckServer:
def __init__(self):
self.server: asyncio.Server | None = None
self.logger = logging.getLogger(__name__)
async def start(self, config: HealthCheckConfig) -> None:
try:
self.server = await asyncio.start_server(
self._handle_request, config.host, config.port
)
# for OS-assigned port in tests
actual_port = self.server.sockets[0].getsockname()[1]
self.logger.info(
f"Health check server listening on {config.host}, port {actual_port}"
)
except OSError as e:
if e.errno == errno.EADDRINUSE:
raise OSError(f"Port {config.port} is already in use") from e
else:
raise
async def stop(self) -> None:
if self.server:
self.server.close()
await self.server.wait_closed()
self.server = None
self.logger.info("Health check server stopped")
async def _handle_request(
self, _reader: asyncio.StreamReader, writer: asyncio.StreamWriter
) -> None:
try:
writer.write(HEALTH_CHECK_RESPONSE)
await writer.drain()
except Exception:
pass
finally:
writer.close()
await writer.wait_closed()
@@ -0,0 +1,37 @@
import sys
from src.config.security_config import SecurityConfig
from src.constants import ERROR_STDLIB_DISALLOWED, ERROR_EXTERNAL_DISALLOWED
def validate_module_import(
module_path: str,
security_config: SecurityConfig,
) -> tuple[bool, str | None]:
stdlib_allow = security_config.stdlib_allow
external_allow = security_config.external_allow
module_name = module_path.split(".")[0]
is_stdlib = module_name in sys.stdlib_module_names
is_external = not is_stdlib
if is_stdlib and ("*" in stdlib_allow or module_name in stdlib_allow):
return (True, None)
if is_external and ("*" in external_allow or module_name in external_allow):
return (True, None)
if is_stdlib:
stdlib_allowed_str = ", ".join(sorted(stdlib_allow)) if stdlib_allow else "none"
error_msg = ERROR_STDLIB_DISALLOWED.format(
module=module_path, allowed=stdlib_allowed_str
)
else:
external_allowed_str = (
", ".join(sorted(external_allow)) if external_allow else "none"
)
error_msg = ERROR_EXTERNAL_DISALLOWED.format(
module=module_path, allowed=external_allowed_str
)
return (False, error_msg)
@@ -0,0 +1,66 @@
import sys
import logging
import os
from src.constants import LOG_FORMAT, LOG_TIMESTAMP_FORMAT, ENV_LAUNCHER_LOG_LEVEL
COLORS = {
"DEBUG": "\033[34m", # blue
"INFO": "\033[32m", # green
"WARNING": "\033[33m", # yellow
"ERROR": "\033[31m", # red
"CRITICAL": "\033[31m", # red
}
RESET = "\033[0m"
class ColorFormatter(logging.Formatter):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.use_colors = os.getenv("NO_COLOR") is None
# When started by launcher, log level and timestamp are handled by launcher.
self.short_form = not sys.stdout.isatty()
def format(self, record):
if self.short_form:
return record.getMessage()
formatted = super().format(record)
if not self.use_colors:
return formatted
parts = formatted.split("\t")
if len(parts) >= 3:
timestamp = parts[0]
level = parts[1]
message = " ".join(parts[2:])
level_color = COLORS.get(record.levelname, "")
if level_color:
level = level_color + level + RESET
message = level_color + message + RESET
formatted = f"{timestamp} {level} {message}"
return formatted
def setup_logging():
logger = logging.getLogger()
log_level_str = os.getenv(ENV_LAUNCHER_LOG_LEVEL, "INFO").upper()
log_level = getattr(logging, log_level_str, logging.INFO)
logger.setLevel(log_level)
stream_handler = logging.StreamHandler(sys.stdout)
stream_handler.setFormatter(ColorFormatter(LOG_FORMAT, LOG_TIMESTAMP_FORMAT))
logger.addHandler(stream_handler)
# Hardcoded to INFO as websocket logs are too verbose
logging.getLogger("websockets.client").setLevel(logging.INFO)
logging.getLogger("websockets.server").setLevel(logging.INFO)
logging.getLogger("websockets").setLevel(logging.INFO)
@@ -0,0 +1,72 @@
import asyncio
import logging
import sys
import platform
from src.constants import ERROR_WINDOWS_NOT_SUPPORTED
from src.config.health_check_config import HealthCheckConfig
from src.config.sentry_config import SentryConfig
from src.config.task_runner_config import TaskRunnerConfig
from src.errors import ConfigurationError
from src.logs import setup_logging
from src.task_runner import TaskRunner
from src.shutdown import Shutdown
async def main():
setup_logging()
logger = logging.getLogger(__name__)
sentry = None
sentry_config = SentryConfig.from_env()
if sentry_config.enabled:
from src.sentry import setup_sentry
sentry = setup_sentry(sentry_config)
try:
health_check_config = HealthCheckConfig.from_env()
except ConfigurationError as e:
logger.error(f"Invalid health check configuration: {e}")
sys.exit(1)
health_check_server: "HealthCheckServer | None" = None
if health_check_config.enabled:
from src.health_check_server import HealthCheckServer
health_check_server = HealthCheckServer()
try:
await health_check_server.start(health_check_config)
except OSError as e:
logger.error(f"Failed to start health check server: {e}")
sys.exit(1)
try:
task_runner_config = TaskRunnerConfig.from_env()
except ConfigurationError as e:
logger.error(str(e))
sys.exit(1)
task_runner = TaskRunner(task_runner_config)
logger.info("Starting runner...")
shutdown = Shutdown(task_runner, health_check_server, sentry)
task_runner.on_idle_timeout = shutdown.start_auto_shutdown
try:
await task_runner.start()
except Exception:
logger.error("Unexpected error", exc_info=True)
await shutdown.start_shutdown()
exit_code = await shutdown.wait_for_shutdown()
sys.exit(exit_code)
if __name__ == "__main__":
if platform.system() == "Windows":
print(ERROR_WINDOWS_NOT_SUPPORTED, file=sys.stderr)
sys.exit(1)
asyncio.run(main())
@@ -0,0 +1,141 @@
import json
from dataclasses import asdict
from typing import cast
from src.message_types.broker import NodeMode, TaskSettings
from src.constants import (
BROKER_INFO_REQUEST,
BROKER_RUNNER_REGISTERED,
BROKER_TASK_CANCEL,
BROKER_TASK_OFFER_ACCEPT,
BROKER_TASK_SETTINGS,
BROKER_RPC_RESPONSE,
BROKER_DRAIN,
)
from src.message_types import (
BrokerMessage,
RunnerMessage,
BrokerInfoRequest,
BrokerRunnerRegistered,
BrokerTaskOfferAccept,
BrokerTaskSettings,
BrokerTaskCancel,
BrokerRpcResponse,
BrokerDrain,
)
NODE_MODE_MAP = {
"runOnceForAllItems": "all_items",
"runOnceForEachItem": "per_item",
}
def _get_node_mode(node_mode_str: str) -> NodeMode:
if node_mode_str not in NODE_MODE_MAP:
raise ValueError(f"Unknown nodeMode: {node_mode_str}")
return cast(NodeMode, NODE_MODE_MAP[node_mode_str])
def _parse_task_settings(d: dict) -> BrokerTaskSettings:
try:
# required
task_id = d["taskId"]
settings_dict = d["settings"]
code = settings_dict["code"]
node_mode = _get_node_mode(settings_dict["nodeMode"])
items = settings_dict["items"]
# optional
continue_on_fail = settings_dict.get("continueOnFail", False)
workflow_name = settings_dict.get("workflowName", "Unknown")
workflow_id = settings_dict.get("workflowId", "Unknown")
node_name = settings_dict.get("nodeName", "Unknown")
node_id = settings_dict.get("nodeId", "Unknown")
query = settings_dict.get("query")
except KeyError as e:
raise ValueError(f"Missing field in task settings message: {e}")
return BrokerTaskSettings(
task_id=task_id,
settings=TaskSettings(
code=code,
node_mode=node_mode,
continue_on_fail=continue_on_fail,
items=items,
workflow_name=workflow_name,
workflow_id=workflow_id,
node_name=node_name,
node_id=node_id,
query=query,
),
)
def _parse_task_offer_accept(d: dict) -> BrokerTaskOfferAccept:
try:
task_id = d["taskId"]
offer_id = d["offerId"]
except KeyError as e:
raise ValueError(f"Missing field in task offer acceptance message: {e}")
return BrokerTaskOfferAccept(task_id=task_id, offer_id=offer_id)
def _parse_task_cancel(d: dict) -> BrokerTaskCancel:
try:
task_id = d["taskId"]
reason = d["reason"]
except KeyError as e:
raise ValueError(f"Missing field in task cancel message: {e}")
return BrokerTaskCancel(task_id=task_id, reason=reason)
def _parse_rpc_response(d: dict) -> BrokerRpcResponse:
try:
call_id = d["callId"]
task_id = d["taskId"]
status = d["status"]
except KeyError as e:
raise ValueError(f"Missing field in RPC response message: {e}")
return BrokerRpcResponse(call_id, task_id, status)
MESSAGE_TYPE_MAP = {
BROKER_INFO_REQUEST: lambda _: BrokerInfoRequest(),
BROKER_RUNNER_REGISTERED: lambda _: BrokerRunnerRegistered(),
BROKER_TASK_OFFER_ACCEPT: _parse_task_offer_accept,
BROKER_TASK_SETTINGS: _parse_task_settings,
BROKER_TASK_CANCEL: _parse_task_cancel,
BROKER_RPC_RESPONSE: _parse_rpc_response,
BROKER_DRAIN: lambda _: BrokerDrain(),
}
class MessageSerde:
"""Responsible for deserializing incoming messages and serializing outgoing messages."""
@staticmethod
def deserialize_broker_message(data: str) -> BrokerMessage:
message_dict = json.loads(data)
message_type = message_dict.get("type")
if message_type not in MESSAGE_TYPE_MAP:
raise ValueError(f"Unknown message type: {message_type}")
return MESSAGE_TYPE_MAP[message_type](message_dict)
@staticmethod
def serialize_runner_message(message: RunnerMessage) -> str:
data = asdict(message)
camel_case_data = {
MessageSerde._snake_to_camel_case(k): v for k, v in data.items()
}
return json.dumps(camel_case_data)
@staticmethod
def _snake_to_camel_case(snake_case_str: str) -> str:
parts = snake_case_str.split("_")
return parts[0] + "".join(word.capitalize() for word in parts[1:])
@@ -0,0 +1,39 @@
from .broker import (
BrokerMessage,
BrokerInfoRequest,
BrokerRunnerRegistered,
BrokerTaskOfferAccept,
BrokerTaskSettings,
BrokerTaskCancel,
BrokerRpcResponse,
BrokerDrain,
)
from .runner import (
RunnerMessage,
RunnerInfo,
RunnerTaskOffer,
RunnerTaskAccepted,
RunnerTaskRejected,
RunnerTaskDone,
RunnerTaskError,
RunnerRpcCall,
)
__all__ = [
"BrokerMessage",
"BrokerInfoRequest",
"BrokerRunnerRegistered",
"BrokerTaskOfferAccept",
"BrokerTaskSettings",
"BrokerTaskCancel",
"BrokerRpcResponse",
"BrokerDrain",
"RunnerMessage",
"RunnerInfo",
"RunnerTaskOffer",
"RunnerTaskAccepted",
"RunnerTaskRejected",
"RunnerTaskDone",
"RunnerTaskError",
"RunnerRpcCall",
]
@@ -0,0 +1,87 @@
from dataclasses import dataclass
from typing import Literal, Any
from src.constants import (
BROKER_INFO_REQUEST,
BROKER_RUNNER_REGISTERED,
BROKER_TASK_CANCEL,
BROKER_TASK_OFFER_ACCEPT,
BROKER_TASK_SETTINGS,
BROKER_RPC_RESPONSE,
BROKER_DRAIN,
)
@dataclass
class BrokerInfoRequest:
type: Literal["broker:inforequest"] = BROKER_INFO_REQUEST
@dataclass
class BrokerRunnerRegistered:
type: Literal["broker:runnerregistered"] = BROKER_RUNNER_REGISTERED
@dataclass
class BrokerTaskOfferAccept:
task_id: str
offer_id: str
type: Literal["broker:taskofferaccept"] = BROKER_TASK_OFFER_ACCEPT
NodeMode = Literal["all_items", "per_item"]
Items = list[dict[str, Any]] # INodeExecutionData[]
Query = str | dict[str, Any] | None # tool input
@dataclass
class TaskSettings:
code: str
node_mode: NodeMode
continue_on_fail: bool
items: Items
workflow_name: str
workflow_id: str
node_name: str
node_id: str
query: Query = None
@dataclass
class BrokerTaskSettings:
task_id: str
settings: TaskSettings
type: Literal["broker:tasksettings"] = BROKER_TASK_SETTINGS
@dataclass
class BrokerTaskCancel:
task_id: str
reason: str
type: Literal["broker:taskcancel"] = BROKER_TASK_CANCEL
@dataclass
class BrokerRpcResponse:
call_id: str
task_id: str
status: str
type: Literal["broker:rpcresponse"] = BROKER_RPC_RESPONSE
@dataclass
class BrokerDrain:
type: Literal["broker:drain"] = BROKER_DRAIN
BrokerMessage = (
BrokerInfoRequest
| BrokerRunnerRegistered
| BrokerTaskOfferAccept
| BrokerTaskSettings
| BrokerTaskCancel
| BrokerRpcResponse
| BrokerDrain
)
@@ -0,0 +1,25 @@
from typing import Any, TypedDict
from src.message_types.broker import Items
PrintArgs = list[list[Any]] # Args to all `print()` calls in a Python code task
class TaskErrorInfo(TypedDict):
message: str
description: str
stack: str
stderr: str
class PipeResultMessage(TypedDict):
result: Items
print_args: PrintArgs
class PipeErrorMessage(TypedDict):
error: TaskErrorInfo
print_args: PrintArgs
PipeMessage = PipeResultMessage | PipeErrorMessage
@@ -0,0 +1,74 @@
from dataclasses import dataclass
from typing import Literal, Any
from src.constants import (
RUNNER_INFO,
RUNNER_RPC_CALL,
RUNNER_TASK_ACCEPTED,
RUNNER_TASK_DONE,
RUNNER_TASK_ERROR,
RUNNER_TASK_OFFER,
RUNNER_TASK_REJECTED,
)
@dataclass
class RunnerInfo:
name: str
types: list[str]
type: Literal["runner:info"] = RUNNER_INFO
@dataclass
class RunnerTaskOffer:
offer_id: str
task_type: str
valid_for: int
type: Literal["runner:taskoffer"] = RUNNER_TASK_OFFER
@dataclass
class RunnerTaskAccepted:
task_id: str
type: Literal["runner:taskaccepted"] = RUNNER_TASK_ACCEPTED
@dataclass
class RunnerTaskRejected:
task_id: str
reason: str
type: Literal["runner:taskrejected"] = RUNNER_TASK_REJECTED
@dataclass
class RunnerTaskDone:
task_id: str
data: dict[str, Any]
type: Literal["runner:taskdone"] = RUNNER_TASK_DONE
@dataclass
class RunnerTaskError:
task_id: str
error: dict[str, Any]
type: Literal["runner:taskerror"] = RUNNER_TASK_ERROR
@dataclass
class RunnerRpcCall:
call_id: str
task_id: str
name: str
params: list[Any]
type: Literal["runner:rpc"] = RUNNER_RPC_CALL
RunnerMessage = (
RunnerInfo
| RunnerTaskOffer
| RunnerTaskAccepted
| RunnerTaskRejected
| RunnerTaskDone
| RunnerTaskError
| RunnerRpcCall
)
@@ -0,0 +1,21 @@
import secrets
import string
NANOID_CHARSET = string.ascii_uppercase + string.ascii_lowercase + string.digits
TARGET_NANOID_LEN = 22
CHARSET_LEN = len(NANOID_CHARSET)
# Collision probability is roughly k^2/(2n) where k=IDs generated, n=possibilities
# At 10^12 IDs generated with 62^22 possibilities -> ~1.8e-16 chance of collision
def nanoid() -> str:
chars = []
while len(chars) < TARGET_NANOID_LEN:
index = secrets.randbits(6)
if index < CHARSET_LEN:
chars.append(NANOID_CHARSET[index])
return "".join(chars)
@@ -0,0 +1,85 @@
import json
import os
import threading
from typing import cast
from multiprocessing.connection import Connection
from src.errors import (
InvalidPipeMsgContentError,
InvalidPipeMsgLengthError,
)
from src.message_types.pipe import PipeMessage
from src.constants import PIPE_MSG_PREFIX_LENGTH
type PipeConnection = Connection
class PipeReader(threading.Thread):
"""Background thread that reads result from pipe."""
def __init__(self, read_fd: int, read_conn: PipeConnection):
super().__init__()
self.read_fd = read_fd
self.read_conn = read_conn
self.pipe_message: PipeMessage | None = None
self.message_size: int | None = None # bytes
self.error: Exception | None = None
def run(self):
try:
length_bytes = PipeReader._read_exact_bytes(
self.read_fd, PIPE_MSG_PREFIX_LENGTH
)
length_int = int.from_bytes(length_bytes, "big")
if length_int <= 0:
raise InvalidPipeMsgLengthError(length_int)
self.message_size = length_int
data = PipeReader._read_exact_bytes(self.read_fd, length_int)
parsed_msg = json.loads(data.decode("utf-8"))
self.pipe_message = self._validate_pipe_message(parsed_msg)
except Exception as e:
self.error = e
finally:
self.read_conn.close()
@staticmethod
def _read_exact_bytes(fd: int, n: int) -> bytes:
"""Read exactly n bytes from file descriptor.
Uses os.read() instead of Connection.recv() because recv() pickles.
Preallocates bytearray to avoid repeated reallocation.
"""
result = bytearray(n)
offset = 0
while offset < n:
chunk = os.read(fd, n - offset)
if not chunk:
raise EOFError("Pipe closed before reading all data")
result[offset : offset + len(chunk)] = chunk
offset += len(chunk)
return bytes(result)
def _validate_pipe_message(self, msg) -> PipeMessage:
if not isinstance(msg, dict):
raise InvalidPipeMsgContentError(f"Expected dict, got {type(msg).__name__}")
if "print_args" not in msg:
raise InvalidPipeMsgContentError("Message missing 'print_args' key")
if not isinstance(msg["print_args"], list):
raise InvalidPipeMsgContentError("'print_args' must be a list")
has_result = "result" in msg
has_error = "error" in msg
if not has_result and not has_error:
raise InvalidPipeMsgContentError("Msg is missing 'result' or 'error' key")
if has_result and has_error:
raise InvalidPipeMsgContentError("Msg has both 'result' and 'error' keys")
if has_error and not isinstance(msg["error"], dict):
raise InvalidPipeMsgContentError("'error' must be a dict")
return cast(PipeMessage, msg)
@@ -0,0 +1,118 @@
import logging
from typing import Any
from src.config.sentry_config import SentryConfig
from src.constants import (
EXECUTOR_FILENAMES,
IGNORED_ERROR_TYPES,
LOG_SENTRY_MISSING,
SENTRY_TAG_SERVER_TYPE_KEY,
SENTRY_TAG_SERVER_TYPE_VALUE,
)
class TaskRunnerSentry:
def __init__(self, config: SentryConfig):
self.config = config
self.logger = logging.getLogger(__name__)
def init(self) -> None:
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
integrations = [LoggingIntegration(level=logging.ERROR)]
is_profiling_enabled = self.config.profiles_sample_rate > 0
if is_profiling_enabled:
try:
# Import profiling integration lazily to avoid hard dependency
import sentry_sdk.profiler # noqa: F401
self.logger.info("Sentry profiling integration loaded")
except ImportError:
self.logger.warning(
"Sentry profiling is enabled but sentry-sdk profiling is not available. "
"Install with: uv sync --all-extras"
)
is_tracing_enabled = self.config.traces_sample_rate > 0
if is_profiling_enabled and not is_tracing_enabled:
self.logger.warning(
"Profiling is enabled but tracing is disabled. Profiling will not work."
)
init_options = {
"dsn": self.config.dsn,
"release": f"n8n@{self.config.n8n_version}",
"environment": self.config.environment,
"server_name": self.config.deployment_name,
"before_send": self._filter_out_ignored_errors,
"attach_stacktrace": True,
"send_default_pii": False,
"auto_enabling_integrations": False,
"default_integrations": True,
"integrations": integrations,
}
if self.config.traces_sample_rate > 0:
init_options["traces_sample_rate"] = self.config.traces_sample_rate
if is_profiling_enabled:
init_options["profiles_sample_rate"] = self.config.profiles_sample_rate
sentry_sdk.init(**init_options)
sentry_sdk.set_tag(SENTRY_TAG_SERVER_TYPE_KEY, SENTRY_TAG_SERVER_TYPE_VALUE)
self.logger.info("Sentry ready")
def shutdown(self) -> None:
import sentry_sdk
sentry_sdk.flush(timeout=2.0)
self.logger.info("Sentry stopped")
def _filter_out_ignored_errors(self, event: Any, hint: Any) -> Any | None:
if "exc_info" in hint:
exc_type, _, _ = hint["exc_info"]
for ignored_type in IGNORED_ERROR_TYPES:
if (
isinstance(exc_type, type)
and isinstance(ignored_type, type)
and issubclass(exc_type, ignored_type)
):
return None
for exception in event.get("exception", {}).get("values", []):
if self._is_from_user_code(exception):
return None
exc_type_name = exception.get("type", "")
for ignored_type in IGNORED_ERROR_TYPES:
if ignored_type.__name__ == exc_type_name:
return None
return event
def _is_from_user_code(self, exception: dict[str, Any]):
for frame in exception.get("stacktrace", {}).get("frames", []):
if frame.get("filename", "") in EXECUTOR_FILENAMES:
return True
return False
def setup_sentry(sentry_config: SentryConfig) -> TaskRunnerSentry | None:
if not sentry_config.enabled:
return None
try:
sentry = TaskRunnerSentry(sentry_config)
sentry.init()
return sentry
except ImportError:
logger = logging.getLogger(__name__)
logger.warning(LOG_SENTRY_MISSING)
return None
except Exception as e:
logger = logging.getLogger(__name__)
logger.warning(f"Failed to initialize Sentry: {e}")
return None
@@ -0,0 +1,86 @@
import asyncio
import logging
import signal
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from src.task_runner import TaskRunner
from src.health_check_server import HealthCheckServer
from src.sentry import TaskRunnerSentry
class Shutdown:
"""Responsible for managing the shutdown routine of the task runner."""
def __init__(
self,
task_runner: "TaskRunner",
health_check_server: "HealthCheckServer | None" = None,
sentry: "TaskRunnerSentry | None" = None,
):
self.logger = logging.getLogger(__name__)
self.is_shutting_down = False
self.shutdown_complete = asyncio.Event()
self.exit_code = 0
self.task_runner = task_runner
self.health_check_server = health_check_server
self.sentry = sentry
self._register_handler(signal.SIGINT)
self._register_handler(signal.SIGTERM)
async def start_shutdown(self, custom_timeout: int | None = None):
if self.is_shutting_down:
return
self.is_shutting_down = True
timeout = (
custom_timeout
if custom_timeout is not None
else self.task_runner.config.graceful_shutdown_timeout
)
try:
await asyncio.wait_for(self._perform_shutdown(), timeout=timeout)
self.exit_code = 0
except asyncio.TimeoutError:
self.logger.warning(f"Shutdown timed out after {timeout}s, forcing exit...")
self.exit_code = 1
except Exception as e:
self.logger.error(f"Error during shutdown: {e}", exc_info=True)
self.exit_code = 1
finally:
self.shutdown_complete.set()
async def wait_for_shutdown(self) -> int:
await self.shutdown_complete.wait()
return self.exit_code
def _register_handler(self, sig: signal.Signals):
async def handler():
self.logger.info(f"Received {sig.name} signal, starting shutdown...")
await self.start_shutdown()
try:
asyncio.get_running_loop().add_signal_handler(
sig, lambda: asyncio.create_task(handler())
)
except NotImplementedError:
self.logger.warning(
f"Signal handler for {sig.name} not supported on this platform"
) # e.g. Windows
async def start_auto_shutdown(self):
self.logger.info("Reached idle timeout, starting shutdown...")
await self.start_shutdown(3) # no tasks so no grace period
async def _perform_shutdown(self):
await self.task_runner.stop()
if self.health_check_server:
await self.health_check_server.stop()
if self.sentry:
self.sentry.shutdown()
@@ -0,0 +1,261 @@
import ast
import hashlib
import re
from collections import OrderedDict
from src.errors import SecurityViolationError
from src.import_validation import validate_module_import
from src.config.security_config import SecurityConfig
from src.constants import (
MAX_VALIDATION_CACHE_SIZE,
ERROR_RELATIVE_IMPORT,
ERROR_DANGEROUS_NAME,
ERROR_DANGEROUS_ATTRIBUTE,
ERROR_NAME_MANGLED_ATTRIBUTE,
ERROR_DYNAMIC_IMPORT,
ERROR_DANGEROUS_STRING_PATTERN,
ERROR_MATCH_PATTERN_ATTRIBUTE,
BLOCKED_ATTRIBUTES,
BLOCKED_NAMES,
)
CacheKey = tuple[str, tuple] # (code_hash, allowlists_tuple)
CachedViolations = list[str]
ValidationCache = OrderedDict[CacheKey, CachedViolations]
FORMAT_FIELD_PATTERN = re.compile(r"\{([^}]*)\}")
class SecurityValidator(ast.NodeVisitor):
"""AST visitor that enforces import allowlists and blocks dangerous attribute access."""
def __init__(self, security_config: SecurityConfig):
self.checked_modules: set[str] = set()
self.violations: list[str] = []
self.security_config = security_config
# ========== Detection ==========
def visit_Import(self, node: ast.Import) -> None:
"""Detect bare import statements (e.g., import os), including aliased (e.g., import numpy as np)."""
for alias in node.names:
module_name = alias.name
self._validate_import(module_name, node.lineno)
self.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
"""Detect from import statements (e.g., from os import path)."""
if node.level > 0:
self._add_violation(node.lineno, ERROR_RELATIVE_IMPORT)
elif node.module:
self._validate_import(node.module, node.lineno)
self.generic_visit(node)
def visit_Name(self, node: ast.Name) -> None:
if node.id in BLOCKED_NAMES:
self._add_violation(node.lineno, ERROR_DANGEROUS_NAME.format(name=node.id))
self.generic_visit(node)
def visit_Attribute(self, node: ast.Attribute) -> None:
"""Detect access to unsafe attributes that could bypass security restrictions."""
if node.attr in BLOCKED_ATTRIBUTES:
self._add_violation(
node.lineno, ERROR_DANGEROUS_ATTRIBUTE.format(attr=node.attr)
)
if node.attr.startswith("_") and "__" in node.attr:
parts = node.attr.split("__", 1)
if len(parts) == 2 and parts[0].startswith("_"):
self._add_violation(node.lineno, ERROR_NAME_MANGLED_ATTRIBUTE)
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
"""Detect calls to __import__() that could bypass security restrictions."""
is_import_call = (
# __import__()
(isinstance(node.func, ast.Name) and node.func.id == "__import__")
or
# builtins.__import__() or __builtins__.__import__()
(
isinstance(node.func, ast.Attribute)
and node.func.attr == "__import__"
and isinstance(node.func.value, ast.Name)
and node.func.value.id in {"builtins", "__builtins__"}
)
)
if is_import_call:
if (
node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
):
module_name = node.args[0].value
self._validate_import(module_name, node.lineno)
else:
self._add_violation(node.lineno, ERROR_DYNAMIC_IMPORT)
self.generic_visit(node)
def visit_Subscript(self, node: ast.Subscript) -> None:
"""Detect dict access to blocked attributes, e.g. __builtins__['__spec__']"""
is_builtins_access = (
# __builtins__['__spec__']
(
isinstance(node.value, ast.Name)
and node.value.id in {"__builtins__", "builtins"}
)
# obj.__builtins__['__spec__']
or (
isinstance(node.value, ast.Attribute)
and node.value.attr in {"__builtins__", "builtins"}
)
)
if (
is_builtins_access
and isinstance(node.slice, ast.Constant)
and isinstance(node.slice.value, str)
):
key = node.slice.value
if key in BLOCKED_ATTRIBUTES:
self._add_violation(
node.lineno, ERROR_DANGEROUS_ATTRIBUTE.format(attr=key)
)
self.generic_visit(node)
def visit_Constant(self, node: ast.Constant) -> None:
"""Detect string constants containing dangerous format patterns."""
if isinstance(node.value, str):
self._check_format_string(node.value, node.lineno)
self.generic_visit(node)
def visit_MatchClass(self, node: ast.MatchClass) -> None:
"""Detect match patterns that extract blocked attributes, e.g. `case AttributeError(obj=x)`"""
for attr in node.kwd_attrs:
if attr in BLOCKED_ATTRIBUTES:
self._add_violation(
node.lineno, ERROR_MATCH_PATTERN_ATTRIBUTE.format(attr=attr)
)
self.generic_visit(node)
def _check_format_string(self, s: str, lineno: int) -> None:
"""Check if a string contains format patterns that access blocked attributes."""
# escaped braces produce literal braces, not format fields
s = s.replace("{{", "").replace("}}", "")
for match in FORMAT_FIELD_PATTERN.finditer(s):
field = match.group(1)
# attribute access
for attr_match in re.finditer(r"\.(\w+)", field):
attr = attr_match.group(1)
if attr in BLOCKED_ATTRIBUTES or attr in BLOCKED_NAMES:
self._add_violation(
lineno, ERROR_DANGEROUS_STRING_PATTERN.format(attr=attr)
)
# subscript access
for subscript_match in re.finditer(r"\[(['\"]?)(\w+)\1\]", field):
key = subscript_match.group(2)
if key in BLOCKED_ATTRIBUTES or key in BLOCKED_NAMES:
self._add_violation(
lineno, ERROR_DANGEROUS_STRING_PATTERN.format(attr=key)
)
# ========== Validation ==========
def _validate_import(self, module_path: str, lineno: int) -> None:
"""Validate that a module import is allowed based on allowlists. Also disallow relative imports."""
if module_path.startswith("."):
self._add_violation(lineno, ERROR_RELATIVE_IMPORT)
return
module_name = module_path.split(".")[0] # e.g., os.path -> os
if module_name in self.checked_modules:
return
self.checked_modules.add(module_name)
is_allowed, error_msg = validate_module_import(
module_path, self.security_config
)
if not is_allowed:
assert error_msg is not None
self._add_violation(lineno, error_msg)
def _add_violation(self, lineno: int, message: str) -> None:
self.violations.append(f"Line {lineno}: {message}")
class TaskAnalyzer:
_cache: ValidationCache = OrderedDict()
def __init__(self, security_config: SecurityConfig):
self._security_config = security_config
self._allowlists = (
tuple(sorted(security_config.stdlib_allow)),
tuple(sorted(security_config.external_allow)),
)
self._allow_all = (
"*" in security_config.stdlib_allow
and "*" in security_config.external_allow
)
def validate(self, code: str) -> None:
if self._allow_all:
return
cache_key = self._to_cache_key(code)
cached_violations = self._cache.get(cache_key)
if cached_violations is not None:
self._cache.move_to_end(cache_key)
if len(cached_violations) == 0:
return
self._raise_security_error(cached_violations)
tree = ast.parse(code)
security_validator = SecurityValidator(self._security_config)
security_validator.visit(tree)
self._set_in_cache(cache_key, security_validator.violations)
if security_validator.violations:
self._raise_security_error(security_validator.violations)
def _raise_security_error(self, violations: CachedViolations) -> None:
raise SecurityViolationError(
message="Security violations detected", description="\n".join(violations)
)
def _to_cache_key(self, code: str) -> CacheKey:
code_hash = hashlib.sha256(code.encode()).hexdigest()
return (code_hash, self._allowlists)
def _set_in_cache(self, cache_key: CacheKey, violations: CachedViolations) -> None:
if len(self._cache) >= MAX_VALIDATION_CACHE_SIZE:
self._cache.popitem(last=False) # FIFO
self._cache[cache_key] = violations.copy()
self._cache.move_to_end(cache_key)
@@ -0,0 +1,505 @@
import multiprocessing
import traceback
import textwrap
import json
import io
import os
import sys
import logging
from typing import cast
from src.errors import (
TaskCancelledError,
TaskKilledError,
TaskResultMissingError,
TaskResultReadError,
TaskRuntimeError,
TaskTimeoutError,
TaskSubprocessFailedError,
SecurityViolationError,
)
from src.import_validation import validate_module_import
from src.config.security_config import SecurityConfig
from src.message_types.broker import NodeMode, Items, Query
from src.message_types.pipe import (
PipeResultMessage,
PipeErrorMessage,
TaskErrorInfo,
PrintArgs,
)
from src.pipe_reader import PipeReader
from src.constants import (
EXECUTOR_CIRCULAR_REFERENCE_KEY,
EXECUTOR_USER_OUTPUT_KEY,
EXECUTOR_ALL_ITEMS_FILENAME,
EXECUTOR_PER_ITEM_FILENAME,
SIGTERM_EXIT_CODE,
SIGKILL_EXIT_CODE,
PIPE_MSG_PREFIX_LENGTH,
)
from multiprocessing.context import ForkServerProcess
from multiprocessing.connection import Connection
logger = logging.getLogger(__name__)
MULTIPROCESSING_CONTEXT = multiprocessing.get_context("forkserver")
MAX_PRINT_ARGS_ALLOWED = 100
type PipeConnection = Connection
class TaskExecutor:
"""Responsible for executing Python code tasks in isolated subprocesses."""
@staticmethod
def create_process(
code: str,
node_mode: NodeMode,
items: Items,
security_config: SecurityConfig,
query: Query = None,
) -> tuple[ForkServerProcess, PipeConnection, PipeConnection]:
"""Create a subprocess for executing a Python code task and a pipe for communication."""
fn = (
TaskExecutor._all_items
if node_mode == "all_items"
else TaskExecutor._per_item
)
# thread in runner process reads, subprocess writes
read_conn, write_conn = MULTIPROCESSING_CONTEXT.Pipe(duplex=False)
process = MULTIPROCESSING_CONTEXT.Process(
target=fn,
args=(
code,
items,
write_conn,
security_config,
query,
),
)
return process, read_conn, write_conn
@staticmethod
def execute_process(
process: ForkServerProcess,
read_conn: PipeConnection,
write_conn: PipeConnection,
task_timeout: int,
continue_on_fail: bool,
) -> tuple[Items, PrintArgs, int]:
"""Execute a subprocess for a Python code task."""
print_args: PrintArgs = []
pipe_reader = PipeReader(read_conn.fileno(), read_conn)
pipe_reader.start()
try:
try:
process.start()
except Exception as e:
raise TaskSubprocessFailedError(-1, e)
finally:
write_conn.close()
process.join(timeout=task_timeout)
if process.is_alive():
TaskExecutor.stop_process(process)
raise TaskTimeoutError(task_timeout)
if process.exitcode == SIGTERM_EXIT_CODE:
raise TaskCancelledError()
if process.exitcode == SIGKILL_EXIT_CODE:
raise TaskKilledError()
if process.exitcode != 0:
assert process.exitcode is not None
raise TaskSubprocessFailedError(process.exitcode)
pipe_reader.join(timeout=task_timeout)
if pipe_reader.is_alive():
try:
read_conn.close()
except Exception:
pass
raise TaskResultReadError(
TimeoutError(f"Pipe reader timed out after {task_timeout}s")
)
if pipe_reader.error:
raise TaskResultReadError(pipe_reader.error)
if pipe_reader.pipe_message is None:
raise TaskResultMissingError()
returned = pipe_reader.pipe_message
if "error" in returned:
error_msg = cast(PipeErrorMessage, returned)
raise TaskRuntimeError(error_msg["error"])
if "result" not in returned:
raise TaskResultMissingError()
result_msg = cast(PipeResultMessage, returned)
result = result_msg["result"]
print_args = result_msg.get("print_args", [])
assert pipe_reader.message_size is not None
result_size_bytes = pipe_reader.message_size
return result, print_args, result_size_bytes
except Exception as e:
if continue_on_fail:
return [{"json": {"error": str(e)}}], print_args, 0
raise
@staticmethod
def stop_process(process: ForkServerProcess | None):
"""Stop a running subprocess, gracefully else force-killing."""
if process is None or not process.is_alive():
return
try:
process.terminate()
process.join(timeout=1) # 1s grace period
if process.is_alive():
process.kill()
process.join()
except (ProcessLookupError, ConnectionError, BrokenPipeError):
# subprocess is dead or unreachable
pass
@staticmethod
def _all_items(
raw_code: str,
items: Items,
write_conn,
security_config: SecurityConfig,
query: Query = None,
):
"""Execute a Python code task in all-items mode."""
if security_config.runner_env_deny:
os.environ.clear()
TaskExecutor._sanitize_sys_modules(security_config)
print_args: PrintArgs = []
sys.stderr = stderr_capture = io.StringIO()
try:
wrapped_code = TaskExecutor._wrap_code(raw_code)
compiled_code = compile(wrapped_code, EXECUTOR_ALL_ITEMS_FILENAME, "exec")
globals = {
"__builtins__": TaskExecutor._filter_builtins(security_config),
"_items": items,
"_query": query,
"print": TaskExecutor._create_custom_print(print_args),
}
exec(compiled_code, globals)
result = cast(Items, globals[EXECUTOR_USER_OUTPUT_KEY])
TaskExecutor._put_result(write_conn.fileno(), result, print_args)
except BaseException as e:
TaskExecutor._put_error(
write_conn.fileno(), e, stderr_capture.getvalue(), print_args
)
@staticmethod
def _per_item(
raw_code: str,
items: Items,
write_conn,
security_config: SecurityConfig,
_query: Query = None, # unused, only to keep signatures consistent across modes
):
"""Execute a Python code task in per-item mode."""
if security_config.runner_env_deny:
os.environ.clear()
TaskExecutor._sanitize_sys_modules(security_config)
print_args: PrintArgs = []
sys.stderr = stderr_capture = io.StringIO()
try:
wrapped_code = TaskExecutor._wrap_code(raw_code)
compiled_code = compile(wrapped_code, EXECUTOR_PER_ITEM_FILENAME, "exec")
filtered_builtins = TaskExecutor._filter_builtins(security_config)
custom_print = TaskExecutor._create_custom_print(print_args)
result: Items = []
for index, item in enumerate(items):
globals = {
"__builtins__": filtered_builtins,
"_item": item,
"print": custom_print,
}
exec(compiled_code, globals)
user_output = globals[EXECUTOR_USER_OUTPUT_KEY]
if user_output is None:
continue
json_data = TaskExecutor._extract_json_data_per_item(user_output)
output_item = {"json": json_data, "pairedItem": {"item": index}}
if isinstance(user_output, dict) and "binary" in user_output:
output_item["binary"] = user_output["binary"]
result.append(output_item)
TaskExecutor._put_result(write_conn.fileno(), result, print_args)
except BaseException as e:
TaskExecutor._put_error(
write_conn.fileno(), e, stderr_capture.getvalue(), print_args
)
@staticmethod
def _wrap_code(raw_code: str) -> str:
indented_code = textwrap.indent(raw_code, " ")
return f"def _user_function():\n{indented_code}\n\n{EXECUTOR_USER_OUTPUT_KEY} = _user_function()"
@staticmethod
def _extract_json_data_per_item(user_output):
if not isinstance(user_output, dict):
return user_output
if "json" in user_output:
return user_output["json"]
if "binary" in user_output:
return {k: v for k, v in user_output.items() if k != "binary"}
return user_output
@staticmethod
def _put_result(write_fd: int, result: Items, print_args: PrintArgs):
message: PipeResultMessage = {
"result": result,
"print_args": TaskExecutor._truncate_print_args(print_args),
}
data = json.dumps(message, default=str, ensure_ascii=False).encode("utf-8")
length_bytes = len(data).to_bytes(PIPE_MSG_PREFIX_LENGTH, "big")
try:
TaskExecutor._write_bytes(write_fd, length_bytes)
TaskExecutor._write_bytes(write_fd, data)
finally:
try:
os.close(write_fd)
except Exception:
pass
@staticmethod
def _put_error(
write_fd: int,
e: BaseException,
stderr: str = "",
print_args: PrintArgs | None = None,
):
if print_args is None:
print_args = []
task_error_info: TaskErrorInfo = {
"message": f"Process exited with code {e.code}"
if isinstance(e, SystemExit)
else str(e),
"description": getattr(e, "description", ""),
"stack": traceback.format_exc(),
"stderr": stderr,
}
message: PipeErrorMessage = {
"error": task_error_info,
"print_args": TaskExecutor._truncate_print_args(print_args),
}
data = json.dumps(message, default=str, ensure_ascii=False).encode("utf-8")
length_bytes = len(data).to_bytes(PIPE_MSG_PREFIX_LENGTH, "big")
try:
TaskExecutor._write_bytes(write_fd, length_bytes)
TaskExecutor._write_bytes(write_fd, data)
finally:
try:
os.close(write_fd)
except Exception:
pass
# ========== print() ==========
@staticmethod
def _create_custom_print(print_args: PrintArgs):
def custom_print(*args):
serializable_args = []
for arg in args:
try:
json.dumps(arg, default=str, ensure_ascii=False)
serializable_args.append(arg)
except Exception as _:
# Ensure args are serializable so they are transmissible
# through the multiprocessing queue and via websockets.
serializable_args.append(
{
EXECUTOR_CIRCULAR_REFERENCE_KEY: repr(arg),
"__type__": type(arg).__name__,
}
)
formatted = TaskExecutor._format_print_args(*serializable_args)
print_args.append(formatted)
print("[user code]", *args)
return custom_print
@staticmethod
def _format_print_args(*args) -> list[str]:
"""
Takes the args passed to a `print()` call in user code and converts them
to string representations suitable for display in a browser console.
Expects all args to be serializable.
"""
formatted = []
for arg in args:
if isinstance(arg, str):
formatted.append(f"'{arg}'")
elif arg is None or isinstance(arg, (int, float, bool)):
formatted.append(str(arg))
elif isinstance(arg, dict) and EXECUTOR_CIRCULAR_REFERENCE_KEY in arg:
formatted.append(f"[Circular {arg.get('__type__', 'Object')}]")
else:
formatted.append(json.dumps(arg, default=str, ensure_ascii=False))
return formatted
@staticmethod
def _truncate_print_args(print_args: PrintArgs) -> PrintArgs:
"""Truncate print_args to prevent pipe buffer overflow."""
if not print_args or len(print_args) <= MAX_PRINT_ARGS_ALLOWED:
return print_args
truncated = print_args[:MAX_PRINT_ARGS_ALLOWED]
truncated.append(
[
f"[Output truncated - {len(print_args) - MAX_PRINT_ARGS_ALLOWED} more print statements]"
]
)
return truncated
# ========== security ==========
@staticmethod
def _filter_builtins(security_config: SecurityConfig):
"""Get __builtins__ with denied ones removed."""
if len(security_config.builtins_deny) == 0:
filtered = dict(__builtins__)
else:
filtered = {
k: v
for k, v in __builtins__.items()
if k not in security_config.builtins_deny
}
filtered["__import__"] = TaskExecutor._create_safe_import(security_config)
return filtered
@staticmethod
def _sanitize_sys_modules(security_config: SecurityConfig):
safe_modules = {
"builtins",
"__main__",
"sys",
"traceback",
"linecache",
"importlib",
"importlib.machinery",
}
if "*" in security_config.stdlib_allow:
safe_modules.update(sys.stdlib_module_names)
else:
safe_modules.update(security_config.stdlib_allow)
if "*" in security_config.external_allow:
safe_modules.update(
name
for name in sys.modules.keys()
if name not in sys.stdlib_module_names
)
else:
safe_modules.update(security_config.external_allow)
# keep modules marked as safe and submodules of those
safe_prefixes = [safe + "." for safe in safe_modules]
modules_to_remove = [
name
for name in sys.modules.keys()
if name not in safe_modules
and not any(name.startswith(prefix) for prefix in safe_prefixes)
]
for module_name in modules_to_remove:
del sys.modules[module_name]
@staticmethod
def _create_safe_import(security_config: SecurityConfig):
original_import = __builtins__["__import__"]
def safe_import(name, *args, **kwargs):
is_allowed, error_msg = validate_module_import(name, security_config)
if not is_allowed:
assert error_msg is not None
raise SecurityViolationError(
message="Security violation detected",
description=error_msg,
)
return original_import(name, *args, **kwargs)
return safe_import
# ========== pipe I/O ==========
@staticmethod
def _write_bytes(fd: int, data: bytes):
total_written = 0
while total_written < len(data):
written = os.write(fd, data[total_written:])
if written == 0:
raise OSError("Write failed")
total_written += written
@@ -0,0 +1,510 @@
import asyncio
import logging
import time
from typing import Callable, Awaitable
from dataclasses import dataclass
from urllib.parse import urlparse
import websockets
from websockets.exceptions import InvalidStatus
from websockets.asyncio.client import ClientConnection
import random
from src.errors import TaskCancelledError
from src.config.task_runner_config import TaskRunnerConfig
from src.errors import (
NoIdleTimeoutHandlerError,
TaskMissingError,
WebsocketConnectionError,
)
from src.message_types.broker import TaskSettings
from src.nanoid import nanoid
from src.constants import (
RUNNER_NAME,
TASK_REJECTED_REASON_AT_CAPACITY,
TASK_REJECTED_REASON_OFFER_EXPIRED,
TASK_TYPE_PYTHON,
OFFER_INTERVAL,
OFFER_VALIDITY,
OFFER_VALIDITY_MAX_JITTER,
OFFER_VALIDITY_LATENCY_BUFFER,
TASK_BROKER_WS_PATH,
RPC_BROWSER_CONSOLE_LOG_METHOD,
LOG_TASK_COMPLETE,
LOG_TASK_CANCEL,
LOG_TASK_CANCEL_UNKNOWN,
LOG_TASK_CANCEL_WAITING,
)
from src.message_types import (
BrokerMessage,
RunnerMessage,
BrokerInfoRequest,
BrokerRunnerRegistered,
BrokerTaskOfferAccept,
BrokerTaskSettings,
BrokerTaskCancel,
BrokerRpcResponse,
BrokerDrain,
RunnerInfo,
RunnerTaskOffer,
RunnerTaskAccepted,
RunnerTaskRejected,
RunnerTaskDone,
RunnerTaskError,
RunnerRpcCall,
)
from src.message_serde import MessageSerde
from src.task_state import TaskState, TaskStatus
from src.task_executor import TaskExecutor
from src.task_analyzer import TaskAnalyzer
from src.config.security_config import SecurityConfig
@dataclass
class TaskOffer:
offer_id: str
valid_until: float
@property
def has_expired(self) -> bool:
return time.time() > self.valid_until
class TaskRunner:
def __init__(
self,
config: TaskRunnerConfig,
):
self.runner_id = nanoid()
self.name = RUNNER_NAME
self.config = config
self.websocket_connection: ClientConnection | None = None
self.can_send_offers = False
self.open_offers: dict[str, TaskOffer] = {}
self.running_tasks: dict[str, TaskState] = {}
self.offers_coroutine: asyncio.Task | None = None
self.serde = MessageSerde()
self.executor = TaskExecutor()
self.security_config = SecurityConfig(
stdlib_allow=config.stdlib_allow,
external_allow=config.external_allow,
builtins_deny=config.builtins_deny,
runner_env_deny=config.env_deny,
)
self.analyzer = TaskAnalyzer(self.security_config)
self.logger = logging.getLogger(__name__)
self.idle_coroutine: asyncio.Task | None = None
self.on_idle_timeout: Callable[[], Awaitable[None]] | None = None
self.last_activity_time = time.time()
self.is_shutting_down = False
self.task_broker_uri = config.task_broker_uri
websocket_host = urlparse(config.task_broker_uri).netloc
self.websocket_url = (
f"ws://{websocket_host}{TASK_BROKER_WS_PATH}?id={self.runner_id}"
)
@property
def running_tasks_count(self) -> int:
return len(self.running_tasks)
async def start(self) -> None:
if self.config.is_auto_shutdown_enabled and not self.on_idle_timeout:
raise NoIdleTimeoutHandlerError(self.config.auto_shutdown_timeout)
headers = {"Authorization": f"Bearer {self.config.grant_token}"}
while not self.is_shutting_down:
try:
self.websocket_connection = await websockets.connect(
self.websocket_url,
additional_headers=headers,
max_size=self.config.max_payload_size,
)
self.logger.info("Connected to broker")
await self._listen_for_messages()
except InvalidStatus as e:
if e.response.status_code == 403:
self.logger.error(
f"Authentication failed with status {e.response.status_code}: {e}"
)
raise
self.logger.warning(f"Failed to connect to broker: {e} - retrying...")
except Exception as e:
self.logger.warning(f"Failed to connect to broker: {e} - retrying...")
if not self.is_shutting_down:
self.websocket_connection = None
self.can_send_offers = False
await self._cancel_coroutine(self.offers_coroutine)
await self._cancel_coroutine(self.idle_coroutine)
await asyncio.sleep(5)
async def _cancel_coroutine(self, coroutine: asyncio.Task | None) -> None:
if coroutine and not coroutine.done():
coroutine.cancel()
try:
await coroutine
except asyncio.CancelledError:
pass
# ========== Shutdown ==========
async def stop(self) -> None:
self.is_shutting_down = True
self.can_send_offers = False
await self._cancel_coroutine(self.offers_coroutine)
await self._cancel_coroutine(self.idle_coroutine)
await self._wait_for_tasks()
await self._terminate_tasks()
if self.websocket_connection:
await self.websocket_connection.close()
self.logger.info("Disconnected from broker")
self.logger.info("Runner stopped")
async def _wait_for_tasks(self):
if not self.running_tasks:
return
timeout = self.config.graceful_shutdown_timeout
self.logger.debug(
f"Waiting for {self.running_tasks_count} tasks to complete (timeout: {timeout}s)..."
)
start_time = time.time()
while self.running_tasks and (time.time() - start_time) < timeout:
await asyncio.sleep(0.5)
if self.running_tasks:
self.logger.warning(
f"Timed out waiting for {self.running_tasks_count} tasks to complete"
)
async def _terminate_tasks(self):
if not self.running_tasks:
return
self.logger.warning(f"Terminating {self.running_tasks_count} tasks...")
tasks_to_terminate = [
asyncio.to_thread(self.executor.stop_process, task_state.process)
for task_state in self.running_tasks.values()
if task_state.process
]
if tasks_to_terminate:
await asyncio.gather(*tasks_to_terminate, return_exceptions=True)
self.running_tasks.clear()
self.logger.warning("Terminated tasks")
# ========== Messages ==========
async def _listen_for_messages(self) -> None:
if self.websocket_connection is None:
raise WebsocketConnectionError(self.task_broker_uri)
async for raw_message in self.websocket_connection:
try:
if isinstance(raw_message, bytes):
raw_message = raw_message.decode("utf-8")
message = self.serde.deserialize_broker_message(raw_message)
await self._handle_message(message)
except websockets.ConnectionClosedOK:
break
except Exception as e:
self.logger.error(f"Error handling message: {e}")
async def _handle_message(self, message: BrokerMessage) -> None:
match message:
case BrokerInfoRequest():
await self._handle_info_request()
case BrokerRunnerRegistered():
await self._handle_runner_registered()
case BrokerTaskOfferAccept():
await self._handle_task_offer_accept(message)
case BrokerTaskSettings():
await self._handle_task_settings(message)
case BrokerTaskCancel():
await self._handle_task_cancel(message)
case BrokerRpcResponse():
pass # currently only logging, already handled by browser
case BrokerDrain():
await self._handle_drain()
case _:
self.logger.warning(f"Unhandled message type: {type(message)}")
async def _handle_info_request(self) -> None:
response = RunnerInfo(name=self.name, types=[TASK_TYPE_PYTHON])
await self._send_message(response)
async def _handle_drain(self) -> None:
self.can_send_offers = False
await self._cancel_coroutine(self.offers_coroutine)
self.logger.info("Received drain signal, stopped accepting new tasks")
async def _handle_runner_registered(self) -> None:
self.can_send_offers = True
self.offers_coroutine = asyncio.create_task(self._send_offers_loop())
self.logger.info("Registered with broker")
self._reset_idle_timer()
async def _handle_task_offer_accept(self, message: BrokerTaskOfferAccept) -> None:
offer = self.open_offers.get(message.offer_id)
if offer is None or offer.has_expired:
response = RunnerTaskRejected(
task_id=message.task_id,
reason=TASK_REJECTED_REASON_OFFER_EXPIRED,
)
await self._send_message(response)
return
if self.running_tasks_count >= self.config.max_concurrency:
response = RunnerTaskRejected(
task_id=message.task_id,
reason=TASK_REJECTED_REASON_AT_CAPACITY,
)
await self._send_message(response)
return
del self.open_offers[message.offer_id]
task_state = TaskState(message.task_id)
self.running_tasks[message.task_id] = task_state
response = RunnerTaskAccepted(task_id=message.task_id)
await self._send_message(response)
self.logger.info(f"Accepted task {message.task_id}")
self._reset_idle_timer()
async def _handle_task_settings(self, message: BrokerTaskSettings) -> None:
task_state = self.running_tasks.get(message.task_id)
if task_state is None:
raise TaskMissingError(message.task_id)
if task_state.status != TaskStatus.WAITING_FOR_SETTINGS:
self.logger.warning(
f"Received settings for task but it is already {task_state.status}. Discarding message."
)
return
task_state.workflow_name = message.settings.workflow_name
task_state.workflow_id = message.settings.workflow_id
task_state.node_name = message.settings.node_name
task_state.node_id = message.settings.node_id
task_state.status = TaskStatus.RUNNING
asyncio.create_task(self._execute_task(message.task_id, message.settings))
self.logger.info(f"Received task {message.task_id}")
async def _execute_task(self, task_id: str, task_settings: TaskSettings) -> None:
start_time = time.time()
try:
task_state = self.running_tasks.get(task_id)
if task_state is None:
raise TaskMissingError(task_id)
self.analyzer.validate(task_settings.code)
process, read_conn, write_conn = self.executor.create_process(
code=task_settings.code,
node_mode=task_settings.node_mode,
items=task_settings.items,
security_config=self.security_config,
query=task_settings.query,
)
task_state.process = process
result, print_args, result_size_bytes = await asyncio.to_thread(
self.executor.execute_process,
process=process,
read_conn=read_conn,
write_conn=write_conn,
task_timeout=self.config.task_timeout,
continue_on_fail=task_settings.continue_on_fail,
)
for print_args_per_call in print_args:
await self._send_rpc_message(
task_id, RPC_BROWSER_CONSOLE_LOG_METHOD, print_args_per_call
)
response = RunnerTaskDone(task_id=task_id, data={"result": result})
await self._send_message(response)
self.logger.info(
LOG_TASK_COMPLETE.format(
task_id=task_id,
duration=self._get_duration(start_time),
result_size=self._get_result_size(result_size_bytes),
**task_state.context(),
)
)
except TaskCancelledError as e:
response = RunnerTaskError(task_id=task_id, error={"message": str(e)})
await self._send_message(response)
except SyntaxError as e:
self.logger.warning(f"Task {task_id} failed syntax validation")
error = {"message": str(e)}
response = RunnerTaskError(task_id=task_id, error=error)
await self._send_message(response)
except Exception as e:
self.logger.error(f"Task {task_id} failed", exc_info=True)
error = {
"message": getattr(e, "message", str(e)),
"description": getattr(e, "description", ""),
}
response = RunnerTaskError(task_id=task_id, error=error)
await self._send_message(response)
finally:
self.running_tasks.pop(task_id, None)
self._reset_idle_timer()
async def _handle_task_cancel(self, message: BrokerTaskCancel) -> None:
task_id = message.task_id
task_state = self.running_tasks.get(task_id)
if task_state is None:
self.logger.warning(LOG_TASK_CANCEL_UNKNOWN.format(task_id=task_id))
return
if task_state.status == TaskStatus.WAITING_FOR_SETTINGS:
self.running_tasks.pop(task_id, None)
self.logger.info(LOG_TASK_CANCEL_WAITING.format(task_id=task_id))
await self._send_offers()
return
if task_state.status == TaskStatus.RUNNING:
task_state.status = TaskStatus.ABORTING
await asyncio.to_thread(self.executor.stop_process, task_state.process)
self.logger.info(
LOG_TASK_CANCEL.format(task_id=task_id, **task_state.context())
)
async def _send_rpc_message(self, task_id: str, method_name: str, params: list):
message = RunnerRpcCall(
call_id=nanoid(), task_id=task_id, name=method_name, params=params
)
await self._send_message(message)
async def _send_message(self, message: RunnerMessage) -> None:
if self.websocket_connection is None:
raise WebsocketConnectionError(self.task_broker_uri)
serialized = self.serde.serialize_runner_message(message)
await self.websocket_connection.send(serialized)
# ========== Formatting ==========
def _get_duration(self, start_time: float) -> str:
elapsed = time.time() - start_time
if elapsed < 1:
return f"{int(elapsed * 1000)}ms"
if elapsed < 60:
return f"{int(elapsed)}s"
return f"{int(elapsed) // 60}m"
def _get_result_size(self, size_bytes: int) -> str:
if size_bytes < 1024:
return f"{size_bytes} bytes"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
# ========== Offers ==========
async def _send_offers_loop(self) -> None:
while self.can_send_offers:
try:
await self._send_offers()
await asyncio.sleep(OFFER_INTERVAL)
except asyncio.CancelledError:
break
except Exception as e:
self.logger.error(f"Error sending offers: {e}")
async def _send_offers(self) -> None:
if not self.can_send_offers:
return
expired_offer_ids = [
offer_id
for offer_id, offer in self.open_offers.items()
if offer.has_expired
]
for offer_id in expired_offer_ids:
self.open_offers.pop(offer_id, None)
offers_to_send = self.config.max_concurrency - (
len(self.open_offers) + self.running_tasks_count
)
for _ in range(offers_to_send):
offer_id = nanoid()
valid_for_ms = OFFER_VALIDITY + random.randint(0, OFFER_VALIDITY_MAX_JITTER)
valid_until = (
time.time() + (valid_for_ms / 1000) + OFFER_VALIDITY_LATENCY_BUFFER
)
self.open_offers[offer_id] = TaskOffer(offer_id, valid_until)
message = RunnerTaskOffer(
offer_id=offer_id, task_type=TASK_TYPE_PYTHON, valid_for=valid_for_ms
)
await self._send_message(message)
# ========== Inactivity ==========
def _reset_idle_timer(self):
"""Reset idle timer when key event occurs, namely runner registration, task acceptance, and task completion or failure."""
if not self.config.is_auto_shutdown_enabled:
return
self.last_activity_time = time.time()
if self.idle_coroutine and not self.idle_coroutine.done():
self.idle_coroutine.cancel()
self.idle_coroutine = asyncio.create_task(self._idle_timer_coroutine())
async def _idle_timer_coroutine(self):
try:
await asyncio.sleep(self.config.auto_shutdown_timeout)
if self.running_tasks_count > 0:
return
assert self.on_idle_timeout is not None # validated at start()
await self.on_idle_timeout()
except asyncio.CancelledError:
pass
@@ -0,0 +1,37 @@
from enum import Enum
from dataclasses import dataclass
from multiprocessing.context import ForkServerProcess
class TaskStatus(Enum):
WAITING_FOR_SETTINGS = "waiting_for_settings"
RUNNING = "running"
ABORTING = "aborting"
@dataclass
class TaskState:
task_id: str
status: TaskStatus
process: ForkServerProcess | None = None
workflow_name: str | None = None
workflow_id: str | None = None
node_name: str | None = None
node_id: str | None = None
def __init__(self, task_id: str):
self.task_id = task_id
self.status = TaskStatus.WAITING_FOR_SETTINGS
self.process = None
self.workflow_name = None
self.workflow_id = None
self.node_name = None
self.node_id = None
def context(self):
return {
"node_name": self.node_name,
"node_id": self.node_id,
"workflow_name": self.workflow_name,
"workflow_id": self.workflow_id,
}