Async & networking
s2script integrates Promises with the game frame. Timers and network I/O never block the main thread. Native work has finite admission: Promise APIs reject with named capacity errors, and socket sends return a boolean.
Timers#
import { delay, nextTick, nextFrame, threadSleep, after, every } from '@s2script/sdk/timers';
await nextTick(); // after current microtasks
await nextFrame(); // next game frame
await delay(1000); // ~1s, frame-integrated
await threadSleep(50); // worker-thread sleep; settles on a later game framedelay, nextTick, nextFrame, and threadSleep reject on overload. after and every throw AsyncQueueFull instead of returning a timer. Existing timer cancellation is unchanged. threadSleep returns Promise<void> and settles on a later game frame.
HTTP#
import { fetch } from '@s2script/sdk/http';
const res = await fetch('https://example.com/api', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ steamId }),
timeoutMs: 10_000
});
if (res.ok) {
const data = await res.json();
}HTTP status errors (4xx/5xx) resolve with ok: false (web-fetch parity — useful on the join path). Network failures and timeouts reject. Admission rejects with AsyncQueueFull or AsyncPayloadTooLarge before the request is accepted. A response whose UTF-8 body plus retained headers exceeds the configured http_body_bytes limit (default 10 MiB) rejects with HttpResponseTooLarge.
WebSocket#
import { WebSocket } from '@s2script/sdk/ws';
const ws = await WebSocket.connect('wss://example.com/ws');
ws.onMessage((text) => console.log(text));
ws.onClose((code) => console.log('closed', code));
if (!ws.send('hello')) {
// closed, oversized, or admission full — nothing queued
}
ws.close();Subscribe to onMessage synchronously after await connect so early frames are not missed. Connections are owner-scoped and closed on plugin unload.
WebSocket.send returns boolean. false means closed, oversized, or full admission — nothing was accepted; retry later or close. true means queued, not delivered. Existing callers may ignore the return; structural mocks that returned void must return boolean. Host apiVersion 2 remains compatible.
TCP / UDP#
import { Net } from '@s2script/sdk/net';
const sock = await Net.connectTcp('127.0.0.1', 9000);
sock.onData((bytes) => console.log(bytes.length));
if (!sock.send('hello')) {
// closed, oversized, or admission full — nothing queued
}
const udp = await Net.udp();
if (!udp.sendTo('127.0.0.1', 9000, 'ping')) {
// same contract
}TcpSocket.send and UdpSocket.sendTo use the same boolean admission as WebSocket.send.
Database#
import { Database } from '@s2script/sdk/db';
const db = await Database.open('myplugin');
await db.execute('CREATE TABLE IF NOT EXISTS boots (id INTEGER PRIMARY KEY)');
const rows = await db.query('SELECT * FROM boots');
await db.close();The API is Promise-based (SQLite today; MySQL/Postgres via the same Database seam). Handles are opaque, owner-scoped, and ledgered.
query / execute reject with AsyncQueueFull or AsyncPayloadTooLarge before acceptance. A result that exceeds db_result_rows / db_result_bytes rejects with DatabaseResultTooLarge.
Named errors#
Promise APIs reject with an Error whose .name is one of:
| Name | When |
|---|---|
AsyncQueueFull | A count or queue slot is exhausted |
AsyncPayloadTooLarge | An input exceeds the configured item/aggregate byte limit |
AsyncCancelled | The producer was cancelled before completion |
HttpResponseTooLarge | HTTP body + retained headers exceed http_body_bytes |
DatabaseResultTooLarge | Query rows/bytes exceed db_result_rows / db_result_bytes |
Check err.name, not a message substring.
Frame budgets#
Each game frame drains a bounded batch (frame_items, frame_bytes, frame_poll_items, frame_soft_us). Promise and timer work share that budget with cookie and socket callbacks. The elapsed-time check is soft and runs between units: one JavaScript callback, V8 materialization, or microtask checkpoint can exceed it. One oversized delivery can run alone so work still makes progress.
These are not a whole-process RSS bound. Counts and bytes cover application-owned retention only — allocator overhead, SQLite page caches, protocol/TLS/kernel buffers, and V8 objects retained by JavaScript sit outside the partitions.
Operator configuration
Set S2SCRIPT_ASYNC_LIMITS_JSON on the game process before start. It accepts a JSON object with any subset of the fields below. Unknown fields, zero values, impossible relations, or overflow invalidate the entire override, print a warning, and use defaults. The policy is read once per process — it survives plugin unload and V8 reinitialization. Example: {"frame_items":64,"frame_bytes":1048576}.
Byte units in the table are binary KiB/MiB; the JSON itself uses integers in bytes. These are starting defaults, not measured production bandwidth targets. Tune from rejection and retained-byte metrics during soak. Do not change policy in place while producers from an earlier configuration are still alive.
| Fields | Defaults | Scope |
|---|---|---|
jobs_global, jobs_per_owner | 512, 64 | Admitted producers and retained completions; includes live sockets |
input_bytes, owner_input_bytes, input_item_bytes | 32 MiB, 8 MiB, 8 MiB | Job input aggregate, owner aggregate, single input |
completion_bytes, failure_bytes | 16 MiB, 4096 | Aggregate result reservations; reserved failure allowance per job |
sockets_global, sockets_per_owner | 128, 16 | Actual socket lifetimes, including shutdown in progress |
sqlite_global, sqlite_per_owner | 32, 4 | Actual SQLite actor lifetimes |
pools_global, pools_per_owner | 16, 4 | Actual remote pool lifetimes; each pool has at most four backend connections |
socket_out_items, socket_out_bytes, outbound_bytes | 256, 1 MiB, 16 MiB | Per-socket queued items/bytes (also per-send ceiling), global outbound bytes |
inbound_items, inbound_bytes | 2048, 16 MiB | Aggregate socket data events retained by producers, queues, and dispatch |
timers_global, timers_per_owner | 4096, 512 | Pending and selected timers |
worker_queue_items, sqlite_queue_items | 256, 64 | Waiting jobs in the pool / each SQLite actor |
http_body_bytes | 10 MiB | Final UTF-8 body plus retained response headers |
db_result_rows, db_result_bytes | 10,000, 8 MiB | Rows and normalized columns/cells plus conservative row metadata |
cookie_versions, cookie_bytes, cookie_write_bytes | 4096, 4 MiB, 64 KiB | Cookie outbox: retained versions, aggregate bytes, single write — Cookies |
frame_items, frame_bytes, frame_poll_items, frame_soft_us | 256, 2 MiB, 256, 2000 | Logical deliveries, delivered bytes, source polls, soft elapsed-time stop |
Plugin s2script.config is unrelated — that is the typed per-plugin overlay. See Config.
Diagnostics#
__s2_async_stats() is a native helper (no job). It returns a JSON string; parse it:
const stats = JSON.parse(__s2_async_stats());Gauges are diagnostic and can change concurrently — not one transactional snapshot. Counters persist across isolate reinitialization. rejected counts failed partition acquisitions/growth; early argument-validation errors are not partition rejections.
jobs, completion, sockets, sqlite, pools, inbound, outbound, timers:
{ items: number, bytes: number, rejected: number }
queued: { worker, http, db, ws, net: number }
staged: { timers, ws, net, cookies, http, db: number }
cache: { accounts, entries, bytes: number }
frame: null | { items, bytes, polls: number }
timerExamined: number
lastNs: number
maxNs: numberqueued vs staged is in-flight vs ready-to-deliver. Duration counters (lastNs, maxNs) cover the async drain through the callback phase.