Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Minimal changes to allow manual re-initialization of NodeJS process #131

Merged
merged 6 commits into from
Apr 5, 2024
Merged
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
39 changes: 38 additions & 1 deletion docs/python.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,4 +137,41 @@ print(output, myArray, myObject)
You can also use it inline.
```swift
x_or_z = eval_js(''' obj.x ?? obj.z ''')
```
```

### Controlling the NodeJS process

By default, JSPyBridge spawns a single NodeJS process and all JavaScript calls are handled by it.
It is possible to manually control this process in order to restore a clean NodeJS process, which
can be used to clear memory and to recover from process crashes. The NodeJS process can be manually
terminated and initialized with `terminate()` and `init()`, respectively, as shown in the following
example:

```py
import javascript

javascript.eval_js('console.log("Hello from 1st NodeJS process!")')
javascript.terminate()

javascript.init()
javascript.eval_js('console.log("Hello from 2nd NodeJS process!")')
javascript.terminate()
```

Note that the first NodeJS process does not need to be initialized manually. Thus, calling
`javascript.init()` right after `import javascript` has no effect.

#### Note about process re-initialization

When re-initializing a NodeJS process with `javascript.init()`, please note that other exposed
variables, such as `globalThis`, `console`, etc., are also re-assigned. In this case, it is
recommended to access them using the full module import, as shown in the following example:

```py
import javascript # instead of `from javascript import globalThis`
# ...
now = javascript.globalThis.Date() # instead of `now = globalThis.Date()`
```

By doing this, `globalThis` is evaluated when called and will return a reference to the most
recent NodeJS process, as expected.
21 changes: 12 additions & 9 deletions src/javascript/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@


def init():
global console, globalThis, RegExp, start, stop, abort
if config.event_loop:
return # Do not start event loop again
config.event_loop = events.EventLoop()
start = config.event_loop.startThread
stop = config.event_loop.stopThread
abort = config.event_loop.abortThread
config.event_thread = threading.Thread(target=config.event_loop.loop, args=(), daemon=True)
config.event_thread.start()
config.executor = proxy.Executor(config.event_loop)
config.global_jsi = proxy.Proxy(config.executor, 0)
console = config.global_jsi.console # TODO: Remove this in 1.0
globalThis = config.global_jsi.globalThis
RegExp = config.global_jsi.RegExp
atexit.register(config.event_loop.on_exit)

if config.global_jsi.needsNodePatches():
Expand All @@ -20,6 +27,11 @@ def init():
init()


def terminate():
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add a note for this and the other new API method to the docs along with an explanation or how to use?

if config.event_loop:
config.event_loop.stop()


def require(name, version=None):
calling_dir = None
if name.startswith("."):
Expand All @@ -37,11 +49,6 @@ def require(name, version=None):
return config.global_jsi.require(name, version, calling_dir, timeout=900)


console = config.global_jsi.console # TODO: Remove this in 1.0
globalThis = config.global_jsi.globalThis
RegExp = config.global_jsi.RegExp


def eval_js(js):
frame = inspect.currentframe()
rv = None
Expand All @@ -66,10 +73,6 @@ def decor(fn):
return decor


start = config.event_loop.startThread
stop = config.event_loop.stopThread
abort = config.event_loop.abortThread

# You must use this Once decorator for an EventEmitter in Node.js, otherwise
# you will not be able to off an emitter.
def On(emitter, event):
Expand Down
4 changes: 2 additions & 2 deletions src/javascript/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def writeAll(objs):
else:
j = json.dumps(obj) + "\n"
debug("[py -> js]", int(time.time() * 1000), j)
if not proc:
if not proc or proc.poll() is not None:
sendQ.append(j.encode())
continue
try:
Expand Down Expand Up @@ -162,6 +162,7 @@ def com_io():

for send in sendQ:
proc.stdin.write(send)
sendQ.clear()
proc.stdin.flush()

# FIXME untested
Expand All @@ -175,7 +176,6 @@ def com_io():
com_items.append(item)
if config.event_loop != None:
config.event_loop.queue.put("stdin")
stop()


# FIXME untested
Expand Down
49 changes: 25 additions & 24 deletions src/javascript/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ def wait(self, sec):


class EventExecutorThread(threading.Thread):
running = True
jobs = Queue()
doing = []

def __init__(self):
super().__init__()
self.running = True
self.jobs = Queue()
self.doing = []
self.daemon = True

def add_job(self, request_id, cb_id, job, args):
Expand All @@ -44,32 +44,33 @@ def run(self):
# JS and Python happens through this event loop. Because of Python's "Global Interperter Lock"
# only one thread can run Python at a time, so no race conditions to worry about.
class EventLoop:
active = True
queue = Queue()
freeable = []

callbackExecutor = EventExecutorThread()
def __init__(self):
connection.start()

# This contains a map of active callbacks that we're tracking.
# As it's a WeakRef dict, we can add stuff here without blocking GC.
# Once this list is empty (and a CB has been GC'ed) we can exit.
# Looks like someone else had the same idea :)
# https://stackoverflow.com/questions/21826700/using-python-weakset-to-enable-a-callback-functionality
callbacks = WeakValueDictionary()
self.active = True
self.freeable = []
self.queue = Queue()

# The threads created managed by this event loop.
threads = []
# This contains a map of active callbacks that we're tracking.
# As it's a WeakRef dict, we can add stuff here without blocking GC.
# Once this list is empty (and a CB has been GC'ed) we can exit.
# Looks like someone else had the same idea :)
# https://stackoverflow.com/questions/21826700/using-python-weakset-to-enable-a-callback-functionality
self.callbacks = WeakValueDictionary()

outbound = []
# The threads created managed by this event loop.
self.threads = []

# After a socket request is made, it's ID is pushed to self.requests. Then, after a response
# is recieved it's removed from requests and put into responses, where it should be deleted
# by the consumer.
requests = {} # Map of requestID -> threading.Lock
responses = {} # Map of requestID -> response payload
self.outbound = []

def __init__(self):
connection.start()
# After a socket request is made, it's ID is pushed to self.requests. Then, after a response
# is recieved it's removed from requests and put into responses, where it should be deleted
# by the consumer.
self.requests = {} # Map of requestID -> threading.Lock
self.responses = {} # Map of requestID -> response payload

self.callbackExecutor = EventExecutorThread()
self.callbackExecutor.start()
self.pyi = pyi.PyInterface(self, config.executor)

Expand Down Expand Up @@ -163,7 +164,7 @@ def loop(self):
self.threads = [x for x in self.threads if x[2].is_alive()]

if len(self.freeable) > 40:
self.queue_payload({"r": r, "action": "free", "ffid": "", "args": self.freeable})
self.queue_payload({"r": 0, "action": "free", "ffid": "", "args": self.freeable})
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

About this change: currently this does not work at all because r is not defined. As this calls JS without listening to a return, the request id is ignored so passing "r": 0 works.

self.freeable = []

# Read the inbound data and route it to correct handler
Expand Down
Loading