"""Ephemeral reverse connections and streaming service transport.""" import asyncio import json import re import struct import time from contextlib import suppress from types import SimpleNamespace from urllib.parse import urlsplit, urlencode from uuid import uuid4 import aiohttp from multidict import CIMultiDict from fastapi import HTTPException from open_webui.env import REDIS_KEY_PREFIX, UVICORN_WORKERS from open_webui.models.auth_sessions import AuthSessions from open_webui.models.config import Config CHUNK = 65536 # ~40 MB/s per stream at 100 ms round trip. Buffers fill only while a reader falls behind. WINDOW = 4 * 1024 * 1024 MAX_MESSAGE = 16 * 1024 * 1024 # Used when Tether does not state its own limit. DEFAULT_CONCURRENT_STREAMS = 16 MAX_CONCURRENT_STREAMS = 64 # Both ends may buffer one window per open stream, so streams × window stays within this. BUFFER_BUDGET = MAX_CONCURRENT_STREAMS * WINDOW # The receive window bounds bytes; frames are counted only to bound floods of tiny ones. MIN_FRAME_COST = 64 SAFE_METHODS = ('GET', 'HEAD', 'OPTIONS') # One default for every service: long streams stay open while data flows; silence ends them. TIMEOUT = aiohttp.ClientTimeout(total=None, sock_read=300) class RelayInterrupted(ConnectionError): def __init__(self, reason='Connection lost', disconnected=False): super().__init__( f'Tether connection interrupted: {reason}. ' 'The operation may have completed. Check its result before trying again; do not automatically repeat it.' ) # Set when Tether's socket dropped, as opposed to a timeout or a rejected operation. self.disconnected = disconnected # Set when no response header arrived; with a safe method the request can be repeated once. self.before_response = False self.connection = None self.device = None def result(self): return {'error': str(self), 'code': 'tether_interrupted', 'outcome': 'unknown', 'retryable': False} def get_services(value): if ( not isinstance(value, list) or len(value) > 16 or any(not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9_]{0,31}', name) for name in value) ): raise ValueError('Invalid Tether services') return list(dict.fromkeys(value)) def get_max_concurrent_streams(hello, window): """Tether's stated stream limit, bounded so each open stream can hold a full window.""" value = hello.get('max_concurrent_streams', DEFAULT_CONCURRENT_STREAMS) if type(value) is not int or value < 1: raise ValueError('Invalid stream limit') return min(value, MAX_CONCURRENT_STREAMS, BUFFER_BUDGET // window) def get_connections(value): """Connections the user added in Tether. Each is a relay service of its own, like `open_terminal`; URLs and keys stay on the device.""" if value is None: return [] if not isinstance(value, list) or len(value) > 16: raise ValueError('Invalid Tether connections') connections = [] for item in value: if ( not isinstance(item, dict) or item.get('type') not in ('openai', 'openapi', 'terminal') or not isinstance(item.get('name'), str) ): raise ValueError('Invalid Tether connection') service = get_services([item.get('service')])[0] connections.append({'service': service, 'name': item['name'].strip()[:100] or service, 'type': item['type']}) return connections async def get_tether_connections(user_id, type): """(presence, connection) pairs of this type across the user's online devices.""" return [ (presence, item) for presence in await hub.presences(user_id) for item in presence.get('connections', []) if item['type'] == type and item['service'] in presence.get('services', []) ] def encode(header, data=b''): raw = json.dumps(header, separators=(',', ':')).encode() if len(raw) > 4096 or len(data) > CHUNK: raise ValueError('Relay packet too large') return struct.pack('!I', len(raw)) + raw + data def decode(raw): if len(raw) < 4 or len(raw) > CHUNK + 4100: raise ValueError('Invalid relay packet') length = struct.unpack('!I', raw[:4])[0] if length > 4096 or length > len(raw) - 4: raise ValueError('Invalid relay header') header = json.loads(raw[4 : 4 + length]) if ( not isinstance(header, dict) or not isinstance(header.get('stream'), str) or len(header['stream']) != 36 or type(header.get('sequence')) is not int or header['sequence'] < 0 ): raise ValueError('Invalid relay header') return header, raw[4 + length :] class Stream: def __init__(self, owner, presence): self.id = str(uuid4()) self.owner, self.presence = owner, presence self.sequence = self.incoming = 0 self.window = presence.get('window', 256 * 1024) self.credit = self.receive_credit = self.window self.wake = asyncio.Event() # Model output arrives as many tiny frames; a count of 64 failed streams far inside their window. self.queue = asyncio.Queue(maxsize=self.window // MIN_FRAME_COST) self.closed = False self.lock = asyncio.Lock() self.error = None self.cleanup = None self.timeout_handle = None self.read_timeout = None def set_timeout(self, timeout): self.read_timeout = timeout.sock_read if timeout.sock_read and timeout.sock_read > 0 else None if timeout.total and timeout.total > 0: self.timeout_handle = asyncio.get_running_loop().call_later(timeout.total, self.fail, 'Request timed out') async def send(self, operation, data=b'', **fields): while len(data) > self.credit and not self.closed: self.wake.clear() try: await asyncio.wait_for(self.wake.wait(), 300) except TimeoutError as error: self.fail('Send timed out') raise self.error from error if self.closed: raise self.error or RelayInterrupted('Stream closed') self.credit -= len(data) async with self.lock: if self.closed: raise self.error or RelayInterrupted('Stream closed') header = dict(fields, type=operation, stream=self.id, sequence=self.sequence) self.sequence += 1 try: await self.owner.send(self.presence, header, data) except (OSError, TimeoutError) as error: self.fail(str(error) or 'Send failed', disconnected=isinstance(error, ConnectionError)) raise self.error from error def feed(self, header, data): if self.closed: return if header['sequence'] != self.incoming: if header['type'] == 'error': # Failures sent on another's behalf (a dropped socket, a full device) carry sequence 0. self.fail(str(header.get('message', 'Response interrupted'))[:500], header.get('disconnected') is True) else: self.fail('Missing relay packet') return self.incoming += 1 if header['type'] == 'window': count = header.get('bytes') if type(count) is not int or count <= 0 or self.credit + count > self.window: self.fail('Invalid relay window') else: self.credit += count self.wake.set() return if len(data) > self.receive_credit: self.fail('Receive window exceeded') return self.receive_credit -= len(data) try: self.queue.put_nowait((header, data)) except asyncio.QueueFull: self.fail('Relay consumer too slow') def fail(self, message, disconnected=False): if self.error: return self.error = RelayInterrupted(message, disconnected) self.closed = True self.wake.set() while not self.queue.empty(): self.queue.get_nowait() self.queue.put_nowait(({'type': 'error', 'message': message, 'disconnected': disconnected}, b'')) async def receive(self): if self.error: raise self.error try: header, data = await asyncio.wait_for(self.queue.get(), self.read_timeout) except TimeoutError as error: self.fail('Response timed out') raise self.error from error if header['type'] == 'error': self.fail(str(header.get('message', 'Response interrupted'))[:500], header.get('disconnected') is True) raise self.error return header, data async def consumed(self, count): if count and not self.closed: self.receive_credit += count await self.send('window', bytes=count) async def close(self): if self.cleanup is None: self.closed = True self.wake.set() if self.timeout_handle: self.timeout_handle.cancel() self.cleanup = asyncio.create_task(self._cleanup()) # A cancelled consumer must not interrupt remote cleanup or send it twice. await asyncio.shield(self.cleanup) async def _cleanup(self): try: with suppress(Exception): async with asyncio.timeout(10): async with self.lock: header = dict(type='cancel', stream=self.id, sequence=self.sequence) self.sequence += 1 # send() deliberately rejects failed streams; cancellation must still reach Tether. await self.owner.send(self.presence, header, b'') finally: self.owner.streams.pop(self.id, None) class Connection: """A Tether socket held by this worker.""" def __init__(self, ws, presence): self.ws, self.presence = ws, presence # Stream id -> worker awaiting the replies. self.routes = {} self.lock = asyncio.Lock() # Frames from other workers go through one ordered queue, so a slow socket never blocks the # worker's Redis listener and the other devices it serves. self.outbox = asyncio.Queue() self.queued = 0 self.closing = None self.forwarder = asyncio.create_task(self._forward()) def full(self): return len(self.routes) >= self.presence.get('max_concurrent_streams', DEFAULT_CONCURRENT_STREAMS) async def send(self, header, data): # Lock waiters are FIFO; each stream yields after one chunk. async with self.lock: try: await asyncio.wait_for(self.ws.send_bytes(encode(header, data)), 10) except TimeoutError as error: # A socket that cannot drain fails every stream at once, not each after its own timeout. await self.disconnect() raise ConnectionError('Tether is not receiving') from error def forward(self, header, data): if self.closing: return if self.queued + len(data) > BUFFER_BUDGET: # Never block the listener on a stuck socket: drop the connection in the background. self.closing = asyncio.create_task(self.disconnect()) return self.queued += len(data) self.outbox.put_nowait((header, data)) async def _forward(self): while True: header, data = await self.outbox.get() self.queued -= len(data) try: await self.send(header, data) except Exception: # The router's cleanup fails the affected streams on every worker. await self.disconnect() return async def disconnect(self): with suppress(Exception): async with asyncio.timeout(5): await self.ws.close(code=1011) async def close(self): self.forwarder.cancel() await asyncio.gather(self.forwarder, return_exceptions=True) class Hub: def __init__(self): self.worker = str(uuid4()) self.connections = {} self.streams = {} self.redis = None self.listener = None self.lock = asyncio.Lock() def channel(self, worker): return f'{REDIS_KEY_PREFIX}:tether:worker:{worker}' def key(self, user, connection_id): return f'{REDIS_KEY_PREFIX}:tether:presence:{user}:{connection_id}' async def start(self): async with self.lock: if self.listener and not self.listener.done(): return from open_webui.env import REDIS_URL, REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT, REDIS_CLUSTER from open_webui.utils.redis import get_redis_connection, get_sentinels_from_env sentinels = get_sentinels_from_env(REDIS_SENTINEL_HOSTS, REDIS_SENTINEL_PORT) if not REDIS_URL and not sentinels: if UVICORN_WORKERS > 1: raise HTTPException(503, 'Tether requires Redis with multiple WebUI workers.') return self.redis = get_redis_connection( REDIS_URL, redis_sentinels=sentinels, redis_cluster=REDIS_CLUSTER, async_mode=True, decode_responses=False, ) # RedisCluster can't route a pubsub subscribe until initialize() fills its slot cache. await self.redis.initialize() pubsub = self.redis.pubsub() await pubsub.subscribe(self.channel(self.worker)) self.listener = asyncio.create_task(self.listen(pubsub)) async def listen(self, pubsub): try: async for message in pubsub.listen(): if message['type'] != 'message': continue try: header, data = decode(message['data']) except ValueError: # One malformed frame must not drop every relay this worker holds. continue if header.pop('route', None) == 'connection': connection = self.connections.get(header.pop('connection', '')) if connection: await self.deliver(connection, header.pop('reply', ''), header, data) else: stream = self.streams.get(header['stream']) if stream and header.pop('connection', None) == stream.presence['connection']: stream.feed(header, data) except Exception: for stream in list(self.streams.values()): stream.fail('Tether coordination disconnected') for connection in list(self.connections.values()): with suppress(Exception): await connection.ws.close(code=1011) finally: await pubsub.aclose() async def deliver(self, connection, reply, header, data): """Accept a frame another worker sent to a Tether socket held here.""" stream_id = header['stream'] if header['type'] in ('http-open', 'ws-open'): # This worker holds the socket, so its count is the device's true count. if connection.full(): await self.reject(reply, connection, stream_id, 'Tether has too many active operations.') return connection.routes[stream_id] = reply elif header['type'] != 'notification': if connection.routes.get(stream_id) != reply: return if header['type'] == 'cancel': connection.routes.pop(stream_id, None) connection.forward(header, data) async def reject(self, worker, connection, stream_id, message, disconnected=False): """Fail a remote stream at once instead of leaving it to time out.""" with suppress(Exception): await self.redis.publish( self.channel(worker), encode( { 'type': 'error', 'stream': stream_id, 'sequence': 0, 'route': 'stream', 'connection': connection.presence['connection'], 'message': message, 'disconnected': disconnected, }, b'', ), ) async def presence(self, user_id, connection_id): await self.start() if self.redis: raw = await self.redis.get(self.key(user_id, connection_id)) return json.loads(raw) if raw else None return next( ( c.presence for c in self.connections.values() if c.presence['user'] == user_id and c.presence['connection_id'] == connection_id ), None, ) async def presences(self, user_id): await self.start() if self.redis: records = [] async for key in self.redis.scan_iter(match=self.key(user_id, '*'), count=100): raw = await self.redis.get(key) if raw: records.append(json.loads(raw)) else: records = [connection.presence for connection in self.connections.values()] # SCAN may return a key more than once while connections are changing. return list({record['connection_id']: record for record in records if record['user'] == user_id}.values()) async def open(self, user_id, connection_id, service): presence = await self.presence(user_id, connection_id) if not presence or presence['user'] != user_id or presence['connection_id'] != connection_id: raise HTTPException(503, 'Tether is offline.') row = await AuthSessions.get(presence['session']) if not row or row.user_id != user_id or row.refresh_token_id is None or row.expires_at <= time.time(): raise HTTPException(403, 'Tether session is no longer available.') if service not in presence.get('services', []): raise HTTPException(503, 'Tether service is unavailable.') # Only this worker's streams are visible here; the socket's worker enforces the device total. active = sum(s.presence['connection'] == presence['connection'] for s in self.streams.values()) if active >= presence.get('max_concurrent_streams', DEFAULT_CONCURRENT_STREAMS): raise HTTPException(429, 'Tether has too many active operations.') stream = Stream(self, presence) self.streams[stream.id] = stream return stream async def wait_for_presence(self, user_id, connection_id, service, previous, timeout=20): """Wait for a reconnected Tether, not the socket that just dropped, to offer `service` again.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: with suppress(Exception): presence = await self.presence(user_id, connection_id) if presence and presence['connection'] != previous and service in presence.get('services', []): return True await asyncio.sleep(1) return False async def send(self, presence, header, data): connection = self.connections.get(presence['connection']) if presence['worker'] == self.worker: if not connection: raise ConnectionError('Tether disconnected') if header['type'] in ('http-open', 'ws-open'): if connection.full(): raise HTTPException(429, 'Tether has too many active operations.') connection.routes[header['stream']] = self.worker try: await connection.send(header, data) finally: if header['type'] == 'cancel': connection.routes.pop(header['stream'], None) else: packet = encode( dict(header, route='connection', connection=presence['connection'], reply=self.worker), data ) if not await self.redis.publish(self.channel(presence['worker']), packet): raise ConnectionError('Tether worker is unavailable') async def returned(self, connection, header, data): worker = connection.routes.get(header['stream']) if worker == self.worker: stream = self.streams.get(header['stream']) if stream: stream.feed(header, data) elif worker: await self.redis.publish( self.channel(worker), encode(dict(header, route='stream', connection=connection.presence['connection']), data), ) if header['type'] in ('end', 'error'): connection.routes.pop(header['stream'], None) hub = Hub() async def notify(user_id, notification_id, title, body, path): """Deliver only to authenticated background clients that opted into notifications.""" from open_webui.models.users import Users if not user_id: return presences = [p for p in await hub.presences(user_id) if p.get('notifications')] if not presences: return user = await Users.get_user_by_id(user_id) if ( not user or user.role == 'pending' or not user.settings or not (user.settings.ui or {}).get('notificationEnabled', False) ): return payload = json.dumps( { 'id': str(notification_id)[:200], 'user_id': user_id, 'title': str(title)[:256], 'body': str(body)[:4096], 'path': path, } ).encode() for presence in presences: row = await AuthSessions.get(presence['session']) if not row or row.user_id != user_id or row.refresh_token_id is None or row.expires_at <= time.time(): continue with suppress(Exception): await asyncio.wait_for( hub.send( presence, { 'type': 'notification', 'stream': str(uuid4()), 'sequence': 0, }, payload, ), 10, ) async def get_terminal_connections(user, include_templates=True): """Resolve personal targets alongside existing configured servers.""" user_id = user['id'] if isinstance(user, dict) else user.id connections = list(await Config.get('terminal_server.connections', []) or []) for presence in await hub.presences(user_id): if 'open_terminal' not in presence.get('services', []): continue row = await AuthSessions.get(presence['session']) if not row or row.user_id != user_id or row.refresh_token_id is None or row.expires_at <= time.time(): continue connection_id = presence['connection_id'] connections.append( { 'id': 'tether:' + connection_id, 'name': presence['name'], 'url': 'tether://' + connection_id, 'auth_type': 'none', 'enabled': True, 'owner_id': user_id, 'config': { 'chat_uploads': 'filesystem', 'access_grants': [{'principal_type': 'user', 'principal_id': user_id, 'permission': 'read'}], }, } ) # Terminals the user added in Tether: like the built-in one, but each under its own service. for presence, item in await get_tether_connections(user_id, 'terminal'): connections.append( { 'id': f'tether:{presence["connection_id"]}:{item["service"]}', 'name': f'{presence["name"]} · {item["name"]}', 'url': f'tether://{presence["connection_id"]}', 'service': item['service'], 'auth_type': 'none', 'enabled': True, 'owner_id': user_id, 'config': { 'chat_uploads': 'filesystem', 'access_grants': [{'principal_type': 'user', 'principal_id': user_id, 'permission': 'read'}], }, } ) if include_templates: from open_webui.utils.terminals import list_template_connections managed = [c for c in connections if c.get('server_type') == 'orchestrator'] connections = [c for c in connections if c.get('server_type') != 'orchestrator'] connections.extend(await list_template_connections(user, managed)) return connections class Operation: def __init__(self, coroutine): self.coroutine, self.value = coroutine, None def __await__(self): return self.coroutine.__await__() async def __aenter__(self): self.value = await self.coroutine return self.value async def __aexit__(self, *_): if self.value: await self.value.close() class RelayResponse: def __init__(self, stream, header, upload): self.stream, self.upload = stream, upload self.status = header['status'] self.headers = CIMultiDict({k: str(v) for k, v in header.get('headers', {}).items()}) self.content_type = self.headers.get('Content-Type', '').split(';')[0] self.content = self self.cached = None self.closed = False async def iter_any(self): while True: header, data = await self.stream.receive() if header['type'] == 'end': return if header['type'] != 'data': raise RelayInterrupted('Unexpected response') yield data await self.stream.consumed(len(data)) def iter_chunked(self, size): return self.iter_any() async def iter_chunks(self): # aiohttp yields (data, end_of_http_chunk); the relay has no HTTP chunk boundaries. async for data in self.iter_any(): yield data, False async def read(self): if self.cached is None: parts, size = [], 0 async for data in self.iter_any(): size += len(data) if size > MAX_MESSAGE: await self.close() raise HTTPException(413, 'Response too large to buffer; use streaming.') parts.append(data) self.cached = b''.join(parts) return self.cached async def text(self): return (await self.read()).decode('utf-8', errors='replace') async def json(self, loads=json.loads, **_): return loads(await self.read()) async def close(self): self.closed = True self.upload.cancel() await asyncio.gather(self.upload, return_exceptions=True) await self.stream.close() release = close class RelaySocket: close_code = 1000 def __init__(self, stream): self.stream = stream async def send_bytes(self, value): await self.send(value, 'binary') async def send_str(self, value): await self.send(value.encode(), 'text') async def send(self, value, kind): if len(value) > MAX_MESSAGE: raise ValueError('WebSocket message too large') for offset in range(0, max(1, len(value)), CHUNK): await self.stream.send( 'data', value[offset : offset + CHUNK], kind=kind, final=offset + CHUNK >= len(value) ) def __aiter__(self): return self async def __anext__(self): parts, size = [], 0 while True: header, data = await self.stream.receive() if header['type'] == 'end': self.close_code = header.get('code', 1000) raise StopAsyncIteration if header['type'] != 'data': raise ConnectionError('Invalid WebSocket relay message') size += len(data) if size > MAX_MESSAGE: raise ValueError('WebSocket message too large') parts.append(data) await self.stream.consumed(len(data)) if header.get('final'): value = b''.join(parts) binary = header.get('kind') != 'text' return SimpleNamespace( type=aiohttp.WSMsgType.BINARY if binary else aiohttp.WSMsgType.TEXT, data=value if binary else value.decode(), ) async def close(self, **kwargs): await self.stream.close() class RelayClient: """aiohttp-like client for `tether:///` URLs, served by one of the device's services.""" def __init__(self, user_id, service, timeout=None): if not user_id: raise HTTPException(403, 'A user is required for Tether.') self.user_id = user_id self.service = get_services([service])[0] self.operations = [] self.timeout = timeout if timeout is not None else TIMEOUT self.closed = False async def __aenter__(self): return self async def __aexit__(self, *_): await self.close() async def close(self): self.closed = True await asyncio.gather(*(operation.close() for operation in self.operations), return_exceptions=True) def request(self, method, url, **kwargs): return Operation(self._retrying(method, url, **kwargs)) def ws_connect(self, url, **kwargs): return Operation(self._request(None, url, **kwargs)) def __getattr__(self, name): if name in ('get', 'post', 'put', 'patch', 'delete', 'head', 'options'): return lambda url, **kwargs: self.request(name.upper(), url, **kwargs) raise AttributeError(name) async def _retrying(self, method, url, idempotent=None, **kwargs): """Repeat a safe request once when Tether dropped before any response arrived.""" if idempotent is None: idempotent = method in SAFE_METHODS body = kwargs.get('data') replayable = idempotent and (body is None or isinstance(body, (bytes, bytearray, str))) try: return await self._request(method, url, **kwargs) except RelayInterrupted as error: if not (replayable and error.disconnected and error.before_response and error.connection): raise if not await hub.wait_for_presence(self.user_id, error.device, self.service, error.connection): raise return await self._request(method, url, **kwargs) async def _request(self, method, url, **kwargs): parsed = urlsplit(str(url)) if parsed.scheme != 'tether' or not parsed.hostname: raise ValueError('Invalid Tether URL') device, path = parsed.hostname, parsed.path or '/' headers = dict(kwargs.get('headers') or {}) if self.service == 'open_terminal': # Only the built-in terminal reads this; never send it to the user's own servers. headers['X-User-Id'] = self.user_id query = parsed.query if kwargs.get('params'): query += ('&' if query else '') + urlencode(kwargs['params'], doseq=True) stream = await hub.open(self.user_id, device, self.service) body = kwargs.get('data') if kwargs.get('json') is not None: body = json.dumps(kwargs['json']).encode() headers['Content-Type'] = 'application/json' if isinstance(body, aiohttp.FormData): body = body() headers['Content-Type'] = body.headers['Content-Type'] upload = None try: if method: timeout = kwargs.get('timeout', self.timeout) if not isinstance(timeout, aiohttp.ClientTimeout): timeout = aiohttp.ClientTimeout(total=timeout) stream.set_timeout(timeout) await stream.send( 'http-open' if method else 'ws-open', service=self.service, method=method, path=path, query=query, headers=headers, ) if method: async def write(data): for offset in range(0, len(data), CHUNK): await stream.send('data', data[offset : offset + CHUNK]) async def upload_body(): try: if isinstance(body, str): await write(body.encode()) elif isinstance(body, (bytes, bytearray)): await write(body) elif isinstance(body, aiohttp.payload.Payload): await body.write(SimpleNamespace(write=write)) elif body is not None: async for data in body: await write(data) await stream.send('end') except asyncio.CancelledError: raise except Exception: stream.fail('Upload interrupted; completion may be unknown') upload = asyncio.create_task(upload_body()) # HTTP headers may wait for an upload or command to finish. Only the # WebSocket upgrade has a separate establishment deadline. header, _ = await stream.receive() if method else await asyncio.wait_for(stream.receive(), 30) if method and header['type'] == 'http-response': result = RelayResponse(stream, header, upload) elif not method and header['type'] == 'ws-ready': result = RelaySocket(stream) else: raise ConnectionError('Unexpected relay handshake') self.operations.append(result) return result except BaseException as error: if upload: upload.cancel() await asyncio.gather(upload, return_exceptions=True) await stream.close() if isinstance(error, RelayInterrupted): interrupted = error elif isinstance(error, (OSError, TimeoutError)): interrupted = RelayInterrupted(str(error) or 'Response timed out') else: raise # Everything in this block precedes the response header. interrupted.before_response = True interrupted.connection = stream.presence['connection'] interrupted.device = device if interrupted is error: raise raise interrupted from error def get_terminal_client(url, user_id=None, service='open_terminal', **kwargs): if str(url).startswith('tether://'): return RelayClient(user_id, service=service, timeout=kwargs.get('timeout')) return aiohttp.ClientSession(**kwargs)