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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/komet_node/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from __future__ import annotations

import sys

# Parsing and traversing the KORE world-state configuration (via pyk's recursive-descent
# KORE parser and the recursive cell rewrites in ``interpreter.py``) recurses with the depth
# and size of the term. Large real contracts produce configurations far deeper than CPython's
# default recursion limit (1000), which otherwise surfaces as a ``RecursionError`` mid-request.
# Raise the ceiling to match the rest of the K tooling (pyk sets 10**7; komet sets its own
# limit at import). This is the sole cross-cutting entry point, so setting it here covers the
# server process, direct interpreter use, and the encoders. server.py backs this with a large
# serve-thread stack so a deep term raises a catchable error rather than a SIGSEGV.
sys.setrecursionlimit(10**7)
17 changes: 17 additions & 0 deletions src/komet_node/kdist/node.md
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,23 @@ SCVal arg encoding (key order also significant):
rule #decodeArg({ "type" : "bytes" , "value" : V:String }) => ScBytes(HexBytes(V))
rule #decodeArg({ "type" : "address" , "addrType" : "account" , "value" : V:String }) => ScAddress(Account(HexBytes(V)))
rule #decodeArg({ "type" : "address" , "addrType" : "contract" , "value" : V:String }) => ScAddress(Contract(HexBytes(V)))

// Composite arguments. A vec reuses #decodeArgList (which already yields a List of
// ScVal); a map decodes its entries into a Map from ScVal keys to ScVal values.
// Enums, structs, and tuples all bottom out in vecs and maps, so these two rules
// cover every composite call argument. Encoded by scval_to_json as
// { "type": "vec", "value": [ <scval>, ... ] }
// { "type": "map", "value": [ { "key": <scval>, "val": <scval> }, ... ] }
rule #decodeArg({ "type" : "vec" , "value" : [ ELEMS:JSONs ] }) => ScVec(#decodeArgList(ELEMS))
rule #decodeArg({ "type" : "map" , "value" : [ ENTRIES:JSONs ] }) => ScMap(#decodeMapEntries(ENTRIES))

syntax Map ::= #decodeMapEntries(JSONs) [function]
rule #decodeMapEntries(.JSONs) => .Map
rule #decodeMapEntries(E:JSON, ES:JSONs)
=> #decodeMapEntry(E) #decodeMapEntries(ES)

syntax Map ::= #decodeMapEntry(JSON) [function]
rule #decodeMapEntry({ "key" : K:JSON , "val" : V:JSON }) => #decodeArg(K) |-> #decodeArg(V)
```

`uncheckedCallTx` is like komet's `callTx` but it does not entail a return value check.
Expand Down
14 changes: 14 additions & 0 deletions src/komet_node/scval.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,20 @@ def scval_to_json(scval: SCVal) -> dict:
return {'type': 'address', 'addrType': 'account', 'value': raw.hex()}
assert addr.contract_id is not None
return {'type': 'address', 'addrType': 'contract', 'value': addr.contract_id.contract_id.hash.hex()}
case SCValType.SCV_VEC:
# A vec recurses element-wise. User enums and tuples reduce to vecs at
# the XDR level, so this also covers those composite arguments.
assert scval.vec is not None
return {'type': 'vec', 'value': [scval_to_json(v) for v in scval.vec.sc_vec]}
case SCValType.SCV_MAP:
# A map recurses over its entries. Structs reduce to symbol-keyed maps at
# the XDR level. Key order follows the XDR entry order, which the SDK keeps
# sorted; the K side rebuilds a Map so ordering there is immaterial.
assert scval.map is not None
return {
'type': 'map',
'value': [{'key': scval_to_json(e.key), 'val': scval_to_json(e.val)} for e in scval.map.sc_map],
}
case _:
raise NotImplementedError(f'Unsupported SCVal type for JSON encoding: {scval.type}')

Expand Down
117 changes: 115 additions & 2 deletions src/komet_node/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
import re
import sys
import threading
import time
import traceback
from datetime import datetime, timezone
Expand All @@ -21,7 +22,7 @@
from komet_node.transaction import SimulationRejected, malformed_tx_result_xdr

if TYPE_CHECKING:
from collections.abc import Mapping
from collections.abc import Iterable, Iterator, Mapping
from http.server import HTTPServer as HTTPServerType
from pathlib import Path

Expand Down Expand Up @@ -100,6 +101,13 @@ def _empty_transaction_data() -> str:
# the default 'base64' format; see _require_supported_xdr_format.
_XDR_FORMAT_METHODS: Final = ('getTransaction', 'sendTransaction')

# The request path drives deep Python recursion (pyk's recursive-descent KORE parser and the
# recursive cell rewrites in interpreter.py) proportional to the world-state term. komet_node
# raises the recursion *limit* (see __init__.py) so large real contracts do not hit CPython's
# default 1000; this backs that limit with a matching C stack, run on a dedicated serve thread,
# so a deep term raises a catchable error rather than overflowing an 8 MB stack into a SIGSEGV.
_SERVE_STACK_SIZE: Final = 512 * 1024 * 1024

_log = logging.getLogger('komet_node')


Expand Down Expand Up @@ -177,7 +185,18 @@ def log_message(self, *args: Any) -> None:
# switch to ThreadingHTTPServer without reworking that file protocol.
self._httpd = HTTPServer((self.host, int(self._port)), Handler)
self._log_ready()
self._httpd.serve_forever()

# Run the (blocking) serve loop on a worker thread with a large stack so the raised
# recursion limit is usable: the request handler recurses on this thread, and a big
# C stack is what keeps a deep world-state term from segfaulting. stack_size is a
# no-op fallback (default stack) on the rare platform that does not support it.
try:
threading.stack_size(_SERVE_STACK_SIZE)
except (ValueError, RuntimeError):
pass
worker = threading.Thread(target=self._httpd.serve_forever, name='komet-node-serve')
worker.start()
worker.join()

def _log_ready(self) -> None:
"""Announce, once the socket is bound, where the server listens and how it started."""
Expand Down Expand Up @@ -296,6 +315,8 @@ def _dispatch(self, method: str | None, params: dict[str, Any], request_id: Any,
return self._handle_simulate(params, request_id, now)
if method == 'getLedgerEntries':
return self._get_ledger_entries(params, request_id, now)
if method == 'traceTransaction':
return self._trace_transaction(params, request_id)

envelope = self._read_only_envelope(method, params, request_id, now)
response = self.interpreter.run(self.state_file, self.io_dir, envelope, None)
Expand Down Expand Up @@ -378,6 +399,98 @@ def _get_ledger_entries(self, params: dict[str, Any], request_id: Any, now: str)
raise RpcError.internal()
return format_ledger_entries_response(response, self.store.wasms_dir)

def _trace_transaction(self, params: dict[str, Any], request_id: Any) -> str:
"""Serve a transaction's execution trace directly from its JSONL file.

The trace was streamed to ``traces/trace_<hash>.jsonl`` during ``sendTransaction`` — one
already-valid JSON record per line — so the result array is assembled here in a single
linear pass (join the lines with commas, wrap in brackets). This deliberately bypasses
the interpreter: the semantics reassembled the array by recursively copying the whole
remaining tail once per line, which is O(n^2) in time and memory and OOM-killed the
interpreter on multi-hundred-MB traces. Hash validation mirrors the read-only path.

Each served record is additionally stamped with an ``"executingContract"`` field naming the
contract whose code is executing at that record, reconstructed from the trace's own call-boundary
markers by walking a stack of contract ids (the debug adapter needs it because a callee's
small ``pos`` values collide with the caller's and must be mapped against the right binary):

* a ``callContract`` record (``instr[0] == 'callContract'``) PUSHes ``to.value`` before
tagging, so the record and its whole callee span are tagged with the callee;
* an exit marker (``instr[0]`` starting with ``'endWasm'`` — success ``endWasm`` and trap
``endWasm-error`` alike) is tagged with the current top, THEN pops (guarded against
underflow);
* every other record is tagged with the current top, or JSON ``null`` when the stack is
empty (records before any ``callContract``).

The root ``callContract`` may have no matching ``endWasm``; its span simply runs to the end.
The annotation is byte-preserving: original record bytes are untouched (the tag is injected
before the closing brace) and only the handful of boundary-candidate lines are ever parsed,
so peak memory stays proportional to the trace size — the property this path exists to keep.
"""
tx_hash = params.get('hash')
if not isinstance(tx_hash, str):
raise RpcError.invalid_params("'hash' (string) is required")
if _TX_HASH_RE.fullmatch(tx_hash) is None:
raise RpcError.invalid_params("'hash' must be a 64-character hex string")
trace_file = self.io_dir / 'traces' / f'trace_{tx_hash}.jsonl'
if not trace_file.is_file():
return '{"jsonrpc":"2.0","id":' + json.dumps(request_id) + ',"result":null}'
text = trace_file.read_text()
body = ','.join(self._annotate_trace_lines(text.split('\n')))
return '{"jsonrpc":"2.0","id":' + json.dumps(request_id) + ',"result":[' + body + ']}'

@staticmethod
def _annotate_trace_lines(lines: Iterable[str]) -> Iterator[str]:
"""Yield each non-empty trace line with an ``"executingContract"`` tag injected, tracking
the call-boundary stack across the whole trace. See :meth:`_trace_transaction` for the
rules.

The tag is deliberately named ``executingContract`` rather than ``contract``: a
``contractData`` trace record already carries its own documented top-level ``"contract"``
field (an address object naming the storage-target contract), so injecting our own
``"contract"`` would duplicate and clobber it — ``executingContract`` avoids the collision.

Boundary detection is cheap: a line is ``json.loads``-parsed only when it contains the
substring ``"callContract"`` or ``"endWasm`` (a handful of lines out of the whole trace) —
confirmed against the parsed ``instr[0]``; every other line is tagged with the current top
of stack without being parsed. The stack holds contract-id strings; an empty stack tags a
record with JSON ``null``. A ``callContract`` record's callee id is read defensively (a
malformed record missing ``to``/``value`` pushes ``None`` rather than raising and 500-ing
the served file), so push/pop balance with the ``endWasm*`` markers is preserved and the
malformed span is simply tagged ``executingContract: null``. The tag is injected before the
record's closing brace so the original bytes survive verbatim; a line that does not end in
``}`` (never a valid JSONL record) is left untouched.
"""
stack: list[str | None] = []
for line in lines:
if not line:
continue
pop_after = False
# Only parse boundary CANDIDATES: 'callContract' opens a call, 'endWasm'/'endWasm-error'
# close one. Both endWasm spellings share the '"endWasm' prefix.
if '"callContract"' in line or '"endWasm' in line:
record = json.loads(line)
instr = record.get('instr') if isinstance(record, dict) else None
op = instr[0] if isinstance(instr, list) and instr else None
if op == 'callContract':
# Push before tagging: this record and its callee span carry the callee.
# Read 'to.value' defensively so a malformed record still pushes (as None),
# keeping push/pop balance with the endWasm* markers intact.
to = record.get('to')
addr = to.get('value') if isinstance(to, dict) else None
stack.append(addr)
elif isinstance(op, str) and op.startswith('endWasm'):
# Tag with the finishing callee (still on top), then pop after tagging.
pop_after = True
top = stack[-1] if stack else None
stripped = line.rstrip()
if stripped.endswith('}'):
yield stripped[:-1] + ',"executingContract":' + json.dumps(top) + '}'
else:
yield line
if pop_after and stack: # guard against underflow on an unmatched exit marker
stack.pop()

def _read_only_envelope(
self, method: str | None, params: dict[str, Any], request_id: Any, now: str
) -> dict[str, Any]:
Expand Down
11 changes: 11 additions & 0 deletions src/tests/integration/data/wasm/args.wat
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@
;; _ (Soroban ABI stub)
(func (;4;) (type 0))

;; test_vec / test_map: accept 1 composite arg (a HostVal object handle),
;; return Void. Declared last and referenced by symbolic id so their function
;; indices (and the exports below) do not depend on declaration order —
;; wat2wasm numbers functions by position, ignoring the ;;(;N;) comments.
(func $test_vec (type 1) (param i64) (result i64)
i64.const 2)
(func $test_map (type 1) (param i64) (result i64)
i64.const 2)

(memory (;0;) 16)
(global (;0;) (mut i32) (i32.const 1048576))
(global (;1;) i32 (i32.const 1048576))
Expand All @@ -34,6 +43,8 @@
(export "test_wide_integers" (func 2))
(export "test_symbol" (func 3))
(export "_" (func 4))
(export "test_vec" (func $test_vec))
(export "test_map" (func $test_map))
(export "__data_end" (global 1))
(export "__heap_base" (global 2))
)
Loading
Loading