A rather alarming amount of work for one day.

This commit is contained in:
Kiri 2024-06-29 22:50:35 -07:00
commit f73902bef2
19 changed files with 1336 additions and 0 deletions

2
.gitattributes vendored Normal file
View File

@ -0,0 +1,2 @@
# Normalize EOL for all files that Git considers text files.
* text=auto eol=lf

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
# Godot 4+ specific ignores
.godot/
__pycache__

51
Control.gd Normal file
View File

@ -0,0 +1,51 @@
extends Control
#var packet_socket_server : KiriPacketSocket
var wrapper_instance : KiriPythonWrapperInstance
#var active_connections = []
func _ready():
var jrpc : KiriJSONRPC = KiriJSONRPC.new()
var x = jrpc.make_request("make_request", [ "bar", "wharrgarbl", 123 ], 1)
#var x = jrpc.make_request("can_translate_message", [], 1)
print(x)
var c = jrpc.process_action_safer(x)
print("process action: ", c)
#packet_socket_server = KiriPacketSocket.new()
##packet_socket_server.start_server(["127.0.0.1", 9506])
#packet_socket_server.start_client(["127.0.0.1", 9506])
#active_connections.append(packet_socket_server)
wrapper_instance = KiriPythonWrapperInstance.new(
"/storage/git2/GodotJSONRPCTest/addons/kiripythonrpcwrapper/KiriPythonRPCWrapper/test_module/__init__.py")
wrapper_instance.start_process()
func _process(_delta):
pass
#var next_connection = packet_socket_server.get_next_server_connection()
#if next_connection:
#active_connections.append(next_connection)
#print("GOT A NEW CONNECTION!")
#for conn in active_connections:
#var p = conn.get_next_packet()
#while p:
#print("PACKET: ", p)
#conn.send_packet(p)
#p = conn.get_next_packet()
wrapper_instance.poll()
#print(wrapper_instance.get_status())
wrapper_instance.call_rpc_async("func_to_call", [12345], func(stuff):
print(stuff.response)
)
#wrapper_instance.call_rpc_sync("func_to_call", [12345])

50
KiriJSONRPC.gd Normal file
View File

@ -0,0 +1,50 @@
# KiriJSONRPC
#
# This just wraps JSONRPC and adds a little more sanity-checking, like
# preventing the JSONRPC's own methods from being called by an RPC.
extends JSONRPC
class_name KiriJSONRPC
func process_action_safer(action: Variant, recurse: bool = false) -> Variant:
if action is String:
action = JSON.parse_string(action)
# Do some basic type sanity checking.
var invalid_request : bool = false
if not (action is Dictionary):
invalid_request = true
elif not ("method" in action):
invalid_request = true
elif not (action["method"] is String):
invalid_request = true
if invalid_request:
return make_response_error(
JSONRPC.INVALID_REQUEST,
"Invalid request")
# Exclude JSONRPC's own built-in methods. Why the heck are these allowed
# here?
var method_name = action["method"]
var id = action["id"]
var bad_message_names = [
"make_notification",
"make_request",
"make_response",
"make_response_error",
"process_action",
"process_string",
"set_scope"
]
if method_name in bad_message_names:
return make_response_error(
JSONRPC.METHOD_NOT_FOUND,
"Method not found: " + method_name,
id)
return process_action(action, recurse)

View File

@ -0,0 +1,256 @@
extends RefCounted
class_name KiriPacketSocket
var _should_quit : bool = false
var _packet_buffer : KiriPacketBuffer = KiriPacketBuffer.new()
var _state : KiriSocketState = KiriSocketState.DISCONNECTED
var _outgoing_packet_queue : Array = []
var _state_lock : Mutex = Mutex.new()
var _worker_thread : Thread = null
var _new_connections_to_server : Array = []
var _error_string : String = ""
# This class exists in __init__.py, ported as the original Python. Any
# functional changes to this should be reflected in that implementation as well.
class KiriPacketBuffer:
var _receive_buffer : PackedByteArray = []
var _packet_buffer : Array = []
func _grab_complete_packets():
while len(_receive_buffer) >= 4:
var next_packet_size : int = \
_receive_buffer[0] | \
(_receive_buffer[1] << 8) | \
(_receive_buffer[2] << 16) | \
(_receive_buffer[3] << 24)
if len(_receive_buffer) >= 4 + next_packet_size:
var next_packet = _receive_buffer.slice(4, 4 + next_packet_size)
assert(len(next_packet) == next_packet_size)
_receive_buffer = _receive_buffer.slice(4 + len(next_packet))
_packet_buffer.append(next_packet)
else:
break
func _have_complete_packet():
_grab_complete_packets()
return len(_packet_buffer) > 0
func get_next_packet():
if not _have_complete_packet():
return null
return _packet_buffer.pop_front()
func add_bytes(incoming_bytes : PackedByteArray):
_receive_buffer.append_array(incoming_bytes)
enum KiriSocketState {
DISCONNECTED = 0,
CONNECTING = 1,
CONNECTED = 2,
SERVER_STARTING = 3,
SERVER_LISTENING = 4,
ERROR = 5
}
func _notification(what):
if what == NOTIFICATION_PREDELETE:
assert(not _worker_thread)
func send_packet(packet_bytes : PackedByteArray):
assert(packet_bytes)
_state_lock.lock()
_outgoing_packet_queue.append(packet_bytes)
_state_lock.unlock()
func get_next_packet():
_state_lock.lock()
var ret = _packet_buffer.get_next_packet()
_state_lock.unlock()
return ret
func get_next_server_connection():
_state_lock.lock()
var ret = null
if len(_new_connections_to_server) > 0:
ret = _new_connections_to_server.pop_front()
_state_lock.unlock()
return ret
func get_last_error():
_state_lock.lock()
var ret = _error_string
_state_lock.unlock()
return ret
func is_disconnected_or_error():
_state_lock.lock()
var bad_states = [
KiriSocketState.DISCONNECTED,
KiriSocketState.ERROR
]
var ret : bool = false
if _state in bad_states:
ret = true
_state_lock.unlock()
return ret
func get_state():
_state_lock.lock()
var ret = _state
_state_lock.unlock()
return ret
func start_server(address):
_set_state(KiriSocketState.SERVER_STARTING)
assert(not _worker_thread)
_worker_thread = Thread.new()
_worker_thread.start(_server_thread_func.bind(address))
func start_client(address):
_set_state(KiriSocketState.CONNECTING)
assert(not _worker_thread)
_worker_thread = Thread.new()
_worker_thread.start(_client_thread_func.bind(address))
func stop():
assert(_worker_thread)
_should_quit = true
_worker_thread.wait_to_finish()
_worker_thread = null
_should_quit = false
func is_running():
return not (_worker_thread == null)
func _normal_communication_loop(sock : StreamPeer, address):
while not _should_quit:
if sock.poll() != OK:
break
if sock.get_status() != StreamPeerTCP.STATUS_CONNECTED:
break
# Get new data.
_state_lock.lock()
var available_bytes = sock.get_available_bytes()
if available_bytes > 0:
var incoming_bytes = sock.get_data(available_bytes)
_packet_buffer.add_bytes(PackedByteArray(incoming_bytes[1]))
if incoming_bytes[0] != OK:
break
_state_lock.unlock()
# Send all packets from queue.
_state_lock.lock()
while len(self._outgoing_packet_queue):
var next_outgoing_packet = _outgoing_packet_queue.pop_front()
var len_to_send = len(next_outgoing_packet)
sock.put_u8((len_to_send & 0x000000ff) >> 0)
sock.put_u8((len_to_send & 0x0000ff00) >> 8)
sock.put_u8((len_to_send & 0x00ff0000) >> 16)
sock.put_u8((len_to_send & 0xff000000) >> 24)
sock.put_data(next_outgoing_packet)
_state_lock.unlock()
OS.delay_usec(1)
func _client_thread_func(address):
var sock : StreamPeerTCP = StreamPeerTCP.new()
# Connect to the server.
_set_state(KiriSocketState.CONNECTING)
var connect_err = sock.connect_to_host(address[0], address[1])
if connect_err == OK:
_set_state(KiriSocketState.CONNECTED)
_normal_communication_loop(sock, address)
# We are now disconnected.
_set_state(KiriSocketState.DISCONNECTED)
sock.disconnect_from_host()
else:
_set_state(KiriSocketState.ERROR, "Connection failed")
func _set_state(state : KiriSocketState, error_string=null):
_state_lock.lock()
_state = state
if _state == KiriSocketState.ERROR:
assert(error_string)
_error_string = error_string
else:
assert(not error_string)
_error_string = ""
_state_lock.unlock()
func _server_to_client_thread_func(connection : StreamPeerTCP, address):
_set_state(KiriSocketState.CONNECTED)
_normal_communication_loop(connection, address)
# FIXME: Missing some error handling here due to exception differences
# between Python and GDScript.
# Only switch to "disconnected" if we were most recently
# connected, otherwise we could mask an error.
if get_state() == KiriSocketState.CONNECTED:
_set_state(KiriSocketState.DISCONNECTED)
func _server_thread_func(address):
while not _should_quit:
var sock : TCPServer = TCPServer.new()
var listen_err = sock.listen(address[1], address[0])
if listen_err != OK:
# FIXME: I wonder if we should do this in the main
# thread so we can get the exceptions back up to
# the start_server function and up from there.
_set_state(KiriSocketState.ERROR, "Could not listen on port.")
break
_set_state(KiriSocketState.SERVER_LISTENING)
while not _should_quit:
if sock.is_connection_available():
var connection : StreamPeerTCP = sock.take_connection()
var new_client : KiriPacketSocket = KiriPacketSocket.new()
new_client._start_client_connection_from_server(connection, address)
_state_lock.lock()
_new_connections_to_server.append(new_client)
_state_lock.unlock()
OS.delay_usec(1)
func _start_client_connection_from_server(connection : StreamPeerTCP, address):
assert(not _worker_thread)
_worker_thread = Thread.new()
_worker_thread.start(_server_to_client_thread_func.bind(connection, address))

View File

@ -0,0 +1,20 @@
Copyright © 2024 Kiri Jolly
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
“Software”), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@ -0,0 +1,14 @@
# KiriPacketSocket
TODO - explanation
## Python setup instructions and use
TODO - setup and a simple tutorial
TODO - embed example script
## Godot setup instructions and use
TODO - setup and a simple tutorial
TODO - embed example project (remember to add .gdignore)

View File

@ -0,0 +1,418 @@
#!/usr/bin/python3
# Copyright © 2024 Kiri Jolly
# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the “Software”), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
import socket
import threading
import time
import sys
import enum
class PacketSocket:
"""Socket wrapper that encapsulates its own packet protocol and
manages its own threads. Polling-style interface.
Packets are simply binary blobs with a little-endian 32-bit
unsigned integer size prepended to them. Input data will be split
into individual packets based on these.
TCP/IP only.
Main public API:
- start_client(address) - Connect to an open port.
- start_server(address) - Start a server with an open port. This
will start receiving connections, which much be accepted with
get_next_server_connection().
- get_next_server_connection() - Get the next incoming connection
(returns None if nothing is currently connecting, or a new
PacketSocket that can actually receive packets, otherwise).
Polling interface.
- send_packet(b) - Sends a byte array (b) as a packet. This adds
it to a queue which is send in its own thread.
- get_next_packet() - Gets the next complete incoming packet as a
byte array or None if no packet yet (polling interface).
- get_last_error() - Gets the last error as a string.
- is_disconnected_or_error() - True if we've had a problem (use
get_last_error() to see what the problem was).
- stop() - Shutdown server or disconnect client (undoes
start_client/start_server). Also must be used on the
PacketSocket objects returned by get_next_server_connection().
Internal packet format:
- size: 4-byte little-endian unsigned integer indicating the
number of bytes to follow for the next packet.
- data: As many bytes of data that were specified in the 'size'.
Note: 'size' does not include the space needed for the 'size'
value itself.
Example packet (bytes):
[ 0x03, 0x00, 0x00, 0x00, 0x41, 0x42, 0x43 ]
The first four bytes, [ 0x03, 0x00, 0x00, 0x00 ], indicate the
size of the data, in a little endian unsigned integer. In this
case that decodes to three. The next three bytes, [ 0x41, 0x42,
0x43 ] are the packet data. In this case they represent the ASCII
string "ABC".
To send this example packet, one would use send_packet(b'ABC').
On the receiving side, the return value of get_next_packet() would
be b'ABC'.
This class exists in KiriPacketSocket.gd, ported to GDScript. Any
functional changes to this should be reflected in that
implementation as well.
"""
class PacketBuffer:
"""Receiving buffer for packets. Accumulates bytes until
complete packets are formed.
"""
def __init__(self):
self._receive_buffer = b''
self._packet_buffer = []
def _grab_complete_packets(self):
while len(self._receive_buffer) >= 4:
next_packet_size = int.from_bytes(
self._receive_buffer[0:4],
"little")
if len(self._receive_buffer) >= 4 + next_packet_size:
next_packet = self._receive_buffer[4 : 4 + next_packet_size]
self._receive_buffer = self._receive_buffer[4 + len(next_packet):]
self._packet_buffer.append(next_packet)
else:
break
def _have_complete_packet(self):
self._grab_complete_packets()
return len(self._packet_buffer) > 0
def get_next_packet(self):
if not self._have_complete_packet():
return None
return self._packet_buffer.pop(0)
def add_bytes(self, incoming_bytes):
self._receive_buffer += incoming_bytes
class SocketState(enum.Enum):
DISCONNECTED = 0
CONNECTING = 1
CONNECTED = 2
SERVER_STARTING = 3
SERVER_LISTENING = 4
ERROR = 5
def __init__(self):
self._should_quit = False
self._packet_buffer = self.PacketBuffer()
self._state = self.SocketState.DISCONNECTED
self._outgoing_packet_queue = []
self._state_lock = threading.Lock()
self._worker_thread = None
self._new_connections_to_server = []
self._error_string = None
def __del__(self):
# WE BETTER NOT HAVE ZOMBIE THREADS SITTING AROUND.
assert(not self._worker_thread)
def send_packet(self, packet_bytes):
"""Add a binary blob to the send queue."""
assert(packet_bytes)
with self._state_lock:
assert(packet_bytes)
self._outgoing_packet_queue.append(packet_bytes)
def get_next_packet(self):
"""Get a binary blob from the receive queue."""
with self._state_lock:
ret = self._packet_buffer.get_next_packet()
return ret
def get_next_server_connection(self):
"""For servers: Get the next incoming connection as a PacketSocket instance.
Returns None if there are no incoming connections.
"""
with self._state_lock:
ret = None
if len(self._new_connections_to_server):
ret = self._new_connections_to_server.pop(0)
return ret
def get_last_error(self):
"""Get the last error, as a string. (From the thrown exception.)
Returns None if there has not been an error.
"""
with self._state_lock:
return self._error_string
def is_disconnected_or_error(self):
"""Returns True if this socket has disconnected, or thrown an
error. (One way or another, the connection is gone and
resources may be cleaned up.)
Note: May still have un-processed packets queued.
"""
with self._state_lock:
bad_states = [
self.SocketState.DISCONNECTED,
self.SocketState.ERROR
]
if self._state in bad_states:
return True
return False
def get_state(self):
"""Returns the current SocketState of this object."""
with self._state_lock:
return self._state
def start_server(self, address):
"""For servers: Start a listening server. (And start worker
thread.)
Address is a tuple with a host IP (string) and a port number
(int). Use "0.0.0.0" to open on every interface.
"""
self._set_state(self.SocketState.SERVER_STARTING)
assert(not self._worker_thread)
self._worker_thread = threading.Thread(
target=self._server_thread_func,
args=[address],
daemon=True)
self._worker_thread.start()
def start_client(self, address):
"""For clients: Attempt to connect to a listening server. (And
start worker thread.)
Address is a tuple with a host IP (string) and a port number
(int).
"""
self._set_state(self.SocketState.CONNECTING)
assert(not self._worker_thread)
self._worker_thread = threading.Thread(
target=self._client_thread_func,
args=[address],
daemon=True)
self._worker_thread.start()
def stop(self):
"""Disconnect and shutdown the thread.
For servers: Note that this does not disconnect PacketSocket
instances that were established from this server.
"""
assert(self._worker_thread)
self._should_quit = True
self._worker_thread.join()
self._worker_thread = None
self._should_quit = False
def is_running(self):
return not (self._worker_thread == None)
def _normal_communication_loop(self, sock, address):
"""Shared communication loop between clients and servers."""
# Packet wranging timeout. Should be low so we can send and
# receive packets fast.
sock.settimeout(0.0001)
while not self._should_quit:
# Get new data.
try:
incoming_bytes = sock.recv(1024)
if not incoming_bytes:
break
with self._state_lock:
self._packet_buffer.add_bytes(incoming_bytes)
except TimeoutError:
pass
# Send all packets from queue.
with self._state_lock:
while len(self._outgoing_packet_queue):
next_outgoing_packet = self._outgoing_packet_queue.pop(0)
sock.send(len(next_outgoing_packet).to_bytes(4, "little"))
sock.send(next_outgoing_packet)
def _client_thread_func(self, address):
"""Client startup thread function. Attempts to establishes connection."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
# Connect to the server.
self._set_state(self.SocketState.CONNECTING)
sock.connect(address)
self._set_state(self.SocketState.CONNECTED)
self._normal_communication_loop(sock, address)
# We are now disconnected.
self._set_state(self.SocketState.DISCONNECTED)
sock.close()
except ConnectionError as ex:
self._set_state(self.SocketState.ERROR, str(ex))
def _set_state(self, state, error_string=None):
with self._state_lock:
self._state = state
if self._state == self.SocketState.ERROR:
assert(error_string)
self._error_string = error_string
else:
assert(not error_string)
self._error_string = None
def _server_to_client_thread_func(self, connection, address):
"""Server connection startup thread function. Initiated
internally from a server listening for connections.
"""
self._set_state(self.SocketState.CONNECTED)
try:
self._normal_communication_loop(connection, address)
except ConnectionError as ex:
self._set_state(self.SocketState.ERROR, str(ex))
# Only switch to "disconnected" if we were most recently
# connected, otherwise we could mask an error.
if self.get_state() == self.SocketState.CONNECTED:
self._set_state(self.SocketState.DISCONNECTED)
def _server_thread_func(self, address):
"""Server thread function. Attempts to bind to an address and
listen for incoming connections, in a loop.
"""
while not self._should_quit:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
# Timeout for waiting on incoming connections. This
# can be "large". We aren't doing any message
# wrangling besides getting these in. Worst case we
# add latency to shutdown with this.
sock.settimeout(0.01)
try:
# FXIME: This seems to be for UDP ports?
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(address)
sock.listen()
except Exception as ex:
# FIXME: I wonder if we should do this in the main
# thread so we can get the exceptions back up to
# the start_server function and up from there.
self._set_state(self.SocketState.ERROR, str(ex))
break
self._set_state(self.SocketState.SERVER_LISTENING)
while not self._should_quit:
try:
connection, address = sock.accept()
new_client = PacketSocket()
new_client._start_client_connection_from_server(connection, address)
with self._state_lock:
self._new_connections_to_server.append(new_client)
except TimeoutError:
pass
def _start_client_connection_from_server(self, connection, address):
"""Entrypoint for PacketSocket instances created by servers
accepting connections.
"""
assert(not self._worker_thread)
self._worker_thread = threading.Thread(
target=self._server_to_client_thread_func,
args=(connection, address),
daemon=True)
self._worker_thread.start()

View File

@ -0,0 +1,11 @@
@tool
extends EditorPlugin
func _enter_tree():
# Initialization of the plugin goes here.
pass
func _exit_tree():
# Clean-up of the plugin goes here.
pass

View File

@ -0,0 +1,154 @@
#!/usr/bin/python3
import importlib.util
import sys
import argparse
import time
import psutil
import json
import KiriPacketSocket
# Parse arguments
arg_parser = argparse.ArgumentParser(
prog="KiriPythonRPCWrapper",
description="Wrapper for Python modules to RPCs from Godot.",
epilog="")
arg_parser.add_argument("--script", type=str, required=True)
arg_parser.add_argument("--port", type=int, required=True)
arg_parser.add_argument("--parent_pid", type=int, required=True)
args = arg_parser.parse_args()
# module_path = "../KiriPacketSocket/__init__.py"
# module_name = "KiriPacketSocket"
module_path = args.script
module_name = ""
# Attempt to load the module.
module_spec = importlib.util.spec_from_file_location(
module_name, module_path)
module = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(module)
# This will be all the functions we find in the module that don't
# start with "_".
known_entrypoints = {}
# Scan the module for "public" functions.
for entrypoint in dir(module):
# Skip anything starting with "_". Probably not meant to be
# exposed.
if entrypoint.startswith("_"):
continue
attr = getattr(module, entrypoint)
# if hasattr(attr, "__call__"):
if callable(attr):
known_entrypoints[entrypoint] = attr
# Connect to server.
packet_socket = KiriPacketSocket.PacketSocket()
packet_socket.start_client(("127.0.0.1", args.port))
while packet_socket.get_state() == packet_socket.SocketState.CONNECTING:
time.sleep(0.001)
if packet_socket.get_state() != packet_socket.SocketState.CONNECTED:
packet_socket.stop()
raise Exception("Failed to connect to RPC host.")
print("Starting packet processing.")
def send_error_response(code, message, request_id):
ret_dict = {
"jsonrpc" : "2.0",
"error" : {
"code" : code,
"message" : message
},
"id" : request_id
}
ret_dict_json = json.dumps(ret_dict)
packet_socket.send_packet(ret_dict_json.encode("utf-8"))
def send_response(result, request_id):
try:
ret_dict = {
"jsonrpc" : "2.0",
"result" : ret,
"id" : request_id
}
ret_dict_json = json.dumps(ret_dict)
packet_socket.send_packet(ret_dict_json.encode("utf-8"))
except Exception as e:
send_error_response(-32603, "Error sending result: " + str(e), request_id)
# Start processing packets.
while True:
# Shutdown when we lose connection to host.
if packet_socket.get_state() != packet_socket.SocketState.CONNECTED:
packet_socket.stop()
raise Exception("Disconnected from RPC host.")
# Watch parent PID so we can clean up when needed.
if not psutil.pid_exists(args.parent_pid):
packet_socket.stop()
raise Exception("RPC host process died")
next_packet = packet_socket.get_next_packet()
while next_packet:
this_packet = next_packet
next_packet = packet_socket.get_next_packet()
print("GOT PACKET: ", this_packet)
# FIXME: Handle batches.
# Parse the incoming dict.
try:
request_dict_json = this_packet.decode("utf-8")
request_dict = json.loads(request_dict_json)
except Exception as e:
send_error_response(-32700, "Error parsing packet: " + str(e), request_id)
continue
# Make sure all the fields are there.
try:
method = request_dict["method"]
func_args = request_dict["params"]
request_id = request_dict["id"]
except Exception as e:
send_error_response(-32602, "Missing field: " + str(e), request_id)
continue
# Make sure the method is something we scanned earlier.
try:
func = known_entrypoints[method]
except Exception as e:
send_error_response(-32601, "Method not found: " + str(e), request_id)
continue
# Call the dang function.
try:
ret = func(*func_args)
except Exception as e:
send_error_response(-32603, "Call failed: " + str(e), request_id)
continue
send_response(ret, request_id)
time.sleep(0.0001)

View File

@ -0,0 +1,13 @@
#!/usr/bin/python3
this_is_not_a_thing_to_call = 0
def func_to_call(asdf):
print("called with: ", asdf)
global this_is_not_a_thing_to_call
this_is_not_a_thing_to_call += 1
return str(asdf) + "blah"
def other_func_to_call():
print("jksdmckjsdncjksncs")

View File

@ -0,0 +1,10 @@
#!/usr/bin/python3
import time
try:
import KiriPythonRPCWrapper
except Exception as e:
print(e)
time.sleep(5)

View File

@ -0,0 +1,219 @@
extends RefCounted
class_name KiriPythonWrapperInstance
var external_process_pid = -1
var server_packet_socket : KiriPacketSocket = null
var communication_packet_socket : KiriPacketSocket = null
var python_script_path : String = ""
enum KiriPythonWrapperStatus {
STATUS_RUNNING,
STATUS_STOPPED
}
func _init(python_file_path : String):
python_script_path = python_file_path
func _get_python_executable():
# FIXME: Adjust per-OS. Maybe test a few locations.
return "/usr/bin/python3"
func _get_wrapper_script():
# FIXME: Paths will be different for builds.
var script_path = self.get_script().get_path()
var script_dirname = script_path.get_base_dir()
return ProjectSettings.globalize_path( \
script_dirname + "/KiriPythonRPCWrapper_start.py")
func get_status():
if external_process_pid == -1:
return KiriPythonWrapperStatus.STATUS_STOPPED
if not OS.is_process_running(external_process_pid):
return KiriPythonWrapperStatus.STATUS_STOPPED
return KiriPythonWrapperStatus.STATUS_RUNNING
func start_process():
# FIXME: Make sure we don't have one running.
var open_port = 9500
assert(not server_packet_socket)
server_packet_socket = KiriPacketSocket.new()
while true:
server_packet_socket.start_server(["127.0.0.1", open_port])
# Wait for the server to start.
while server_packet_socket.get_state() == KiriPacketSocket.KiriSocketState.SERVER_STARTING:
OS.delay_usec(1)
# If we're successfully listening, then we found a port to use and we
# don't need to loop anymore.
if server_packet_socket.get_state() == KiriPacketSocket.KiriSocketState.SERVER_LISTENING:
break
# This port is busy. Try the next one.
server_packet_socket.stop()
open_port += 1
print("Port: ", open_port)
var python_exe_path : String = _get_python_executable()
var wrapper_script_path : String = _get_wrapper_script()
var startup_command : Array = [
"xterm", "-e",
python_exe_path,
wrapper_script_path,
"--script", python_script_path,
"--port", open_port,
"--parent_pid", OS.get_process_id()]
print("startup command: ", startup_command)
external_process_pid = OS.create_process(
startup_command[0], startup_command.slice(1), true)
print("external process: ", external_process_pid)
func stop_process():
if external_process_pid != -1:
OS.kill(external_process_pid)
external_process_pid = -1
# Clean up server and communication sockets.
if server_packet_socket:
server_packet_socket.stop()
server_packet_socket = null
if communication_packet_socket:
communication_packet_socket.stop()
communication_packet_socket = null
class KiriPythonWrapperActiveRequest:
enum KiriPythonWrapperActiveRequestState {
STATE_WAITING_TO_SEND,
STATE_SENT,
STATE_RESPONSE_RECEIVED
}
var id : int
var method_name : String
var arguments : Variant # Dictionary or Array
var callback # Callable or null
var state : KiriPythonWrapperActiveRequestState
var response # Return value from the call
var error_response = ""
var _active_request_queue = {}
var _request_counter = 0
func call_rpc_async(method : String, args : Variant, callback = null) -> int:
assert((args is Dictionary) or (args is Array))
assert((callback == null) or (callback is Callable))
var new_request = KiriPythonWrapperActiveRequest.new()
new_request.id = _request_counter
_request_counter += 1
new_request.method_name = method
new_request.arguments = args
new_request.callback = callback
_active_request_queue[new_request.id] = new_request
return new_request.id
func call_rpc_sync(method : String, args : Variant):
# Kinda hacky. We're using arrays because we can change the contents.
# Without the array or something else mutable we'd just end up with the
# internal pointer pointing to different values without affecting these
# ones.
var done_array = [false]
var response_list = []
var request_id = call_rpc_async(method, args, func(request_ob):
done_array[0] = true
response_list.append(request_ob.response)
)
# Wait (block) until we get a response.
while not done_array[0]:
# Bail out if something happened to our instance or connection to it.
if communication_packet_socket:
if communication_packet_socket.is_disconnected_or_error():
break
poll()
OS.delay_usec(1)
if len(response_list):
return response_list[0]
return null
func poll():
# Hand-off between listening socket and actual communications socket.
if server_packet_socket:
communication_packet_socket = server_packet_socket.get_next_server_connection()
if communication_packet_socket:
server_packet_socket.stop()
server_packet_socket = null
if communication_packet_socket:
# Send all waiting requests
for request_id in _active_request_queue:
var request : KiriPythonWrapperActiveRequest = _active_request_queue[request_id]
if request.state == request.KiriPythonWrapperActiveRequestState.STATE_WAITING_TO_SEND:
var request_dict = {
"jsonrpc": "2.0",
"method": request.method_name,
"params": request.arguments,
"id": request.id
}
var request_dict_json = JSON.stringify(request_dict)
communication_packet_socket.send_packet(request_dict_json.to_utf8_buffer())
request.state = request.KiriPythonWrapperActiveRequestState.STATE_SENT
# Check for responses.
var packet = communication_packet_socket.get_next_packet()
while packet != null:
var packet_dict = JSON.parse_string(packet.get_string_from_utf8())
if packet_dict:
if packet_dict.has("id"):
var request_id = packet_dict["id"]
# floats aren't even allowed in JSON RPC as an id. Probably
# meant it to be an int.
if request_id is float:
request_id = int(request_id)
if _active_request_queue.has(request_id):
var request : KiriPythonWrapperActiveRequest = \
_active_request_queue[request_id]
if "result" in packet_dict:
request.response = packet_dict["result"]
else:
request.error_response = "Couldn't find result on packet."
if request.callback:
request.callback.call(request)
# Clean up request.
_active_request_queue.erase(request_id)
packet = communication_packet_socket.get_next_packet()

View File

@ -0,0 +1,7 @@
[plugin]
name="KiriPythonRPCWrapper"
description=""
author="Kiri"
version=""
script="KiriPythonRPCWrapper.gd"

View File

@ -0,0 +1,28 @@
#!/usr/bin/python3
import time
import KiriPacketSocket
ps = KiriPacketSocket.PacketSocket()
ps.start_server(("127.0.0.1", 9506))
connections = []
while True:
psc = ps.get_next_server_connection()
if psc:
print("Got connection.")
connections.append(psc)
psc.send_packet(b'ABCDEFGHIJ')
for conn in connections:
p = conn.get_next_packet()
while p:
print(p)
conn.send_packet(p + b'1')
p = conn.get_next_packet()
time.sleep(0.0001)

12
control.tscn Normal file
View File

@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3 uid="uid://bl7j8w8guq0ns"]
[ext_resource type="Script" path="res://Control.gd" id="1_xu82u"]
[node name="Control" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1_xu82u")

1
icon.svg Normal file
View File

@ -0,0 +1 @@
<svg height="128" width="128" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="2" width="124" height="124" rx="14" fill="#363d52" stroke="#212532" stroke-width="4"/><g transform="scale(.101) translate(122 122)"><g fill="#fff"><path d="M105 673v33q407 354 814 0v-33z"/><path fill="#478cbf" d="m105 673 152 14q12 1 15 14l4 67 132 10 8-61q2-11 15-15h162q13 4 15 15l8 61 132-10 4-67q3-13 15-14l152-14V427q30-39 56-81-35-59-83-108-43 20-82 47-40-37-88-64 7-51 8-102-59-28-123-42-26 43-46 89-49-7-98 0-20-46-46-89-64 14-123 42 1 51 8 102-48 27-88 64-39-27-82-47-48 49-83 108 26 42 56 81zm0 33v39c0 276 813 276 813 0v-39l-134 12-5 69q-2 10-14 13l-162 11q-12 0-16-11l-10-65H447l-10 65q-4 11-16 11l-162-11q-12-3-14-13l-5-69z"/><path d="M483 600c3 34 55 34 58 0v-86c-3-34-55-34-58 0z"/><circle cx="725" cy="526" r="90"/><circle cx="299" cy="526" r="90"/></g><g fill="#414042"><circle cx="307" cy="532" r="60"/><circle cx="717" cy="532" r="60"/></g></g></svg>

After

Width:  |  Height:  |  Size: 950 B

37
icon.svg.import Normal file
View File

@ -0,0 +1,37 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dd4sp7xpjgqrp"
path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://icon.svg"
dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1
svg/scale=1.0
editor/scale_with_editor_scale=false
editor/convert_colors_with_editor_theme=false

29
project.godot Normal file
View File

@ -0,0 +1,29 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="GodotJSONRPCTest"
config/features=PackedStringArray("4.2", "GL Compatibility")
run/max_fps=60
config/icon="res://icon.svg"
[display]
window/vsync/vsync_mode=0
[editor_plugins]
enabled=PackedStringArray("res://addons/kiripythonrpcwrapper/plugin.cfg")
[rendering]
renderer/rendering_method="gl_compatibility"
renderer/rendering_method.mobile="gl_compatibility"