How to bind modules between tasks?

I am trying to communicate to the repl remotely, but I am having problems in binding the Main module used by the repl and the Main module used by the server and I don’t know how to fix it.

I prepared a couple of scripts to reproduce the issue.

Setup

Server side

Create a test_module.jl with the following content:

module MyModule

using Sockets

# -------------------------------
# Server configuration
# -------------------------------
const _HOST = ip"127.0.0.1"
const _PORT = 8765
const _server_running = Ref(true)

# -------------------------------
# Handlers
# -------------------------------
function get_repl_variables(conn, id, params)
  println("active_module server: ", Base.active_module())
  println("Main objectid server: ", objectid(Main))
  println(names(Main, all=true))
end

# -------------------------------
# Client handler
# -------------------------------
function handle_client(conn::TCPSocket, addr)
  println("Client connected from $addr")
  try
    msg = read(conn, 1)
    get_repl_variables(conn, nothing, nothing)
  catch e
    println("Client error: $e")
  finally
    close(conn)
  end
end

# -------------------------------
# Server loop
# -------------------------------
function start_server()
  server = listen(_HOST,_PORT)
  println("Julia TCP server running on $_HOST:$_PORT")

  while _server_running[]
    try
      conn = accept(server)
      handle_client(conn,getpeername(conn))
    catch e
      println("Accept error: $e")
    end
  end

  println("Server stopped")
end

# -------------------------------
# Run server in background
# -------------------------------
@async start_server()

# End of module
end

The module is quite easy: it create a server that is supposed to interact with the repl.
When a message is received, then the server calls the function get_repl_variables() that shall print some information about the Main module.

Client side

Next, we should send some message to the server and see what happens in the repl.
You can create a send_request.sh script with the following content (the script use zsh, but I think you can just change the shebang to use it with bash or similar).

#!/usr/bin/env zsh

HOST="127.0.0.1"
PORT=8765

# Send just a few bytes to trigger the server
echo -n "ping" | nc $HOST $PORT

Make the above script executable with chmod +x send_request.sh.

Basic test

At this point, run julia -i test_module.jl. This should start the repl and create the server.
Then, open another terminal windows and run ./send_request.sh.

In the repl, you should see the following:

active_module server: Main
Main objectid server: 8699491554302981441
[Symbol("##meta#60"), :Base, :Core, :Main, :MyModule, :eval, :include]

Now, from the repl, run println("active_module repl: ", Base.active_module()) followed by println("Main object id repl: ", objectid(Main)) and you should see the following

active_module repl: Main
Main object id repl: 8699491554302981441

Finally, from the repl run println(names(Main, all=true)) and you should see the following:

[Symbol("##meta#60"), :Base, :Core, :Main, :MyModule, :eval, :include]

So far so good.

Here comes the issue

Now, from the repl, type A = 10, followed by println(names(Main, all=true)). You should see the following:

[Symbol("##meta#60"), :A, :Base, :Core, :Main, :MyModule, :eval, :include]

As you see, A is in the Main scope.

Next, run ./send_request.sh. You get the following:

[Symbol("##meta#60"), :Base, :Core, :Main, :MyModule, :eval, :include]

BOOM! The variable A is not included.

It seems that either the subprocess has its own Main that does not update when the repl Main is updated - which is strange given that both the Main:s have the same object_id and in both cases is the active module - or that the Main used in the server side is somehow frozen or it reads from some cached values.

Interestingly enough, by running MyModule.get_repl_variables(1,2,3) from the repl, you get:

active_module server: Main
Main objectid server: 8699491554302981441
[Symbol("##meta#60"), :A, :Base, :Core, :Main, :MyModule, :eval, :include]

I have no idea why it does not work when you go through the server, and TBH I don’t know if this is a bug or not. Perhaps the repl namespace has some dedicated area that I fail to see and from where I should pick up info instead of using names(Main, all=true) from the server side?
Perhaps from the server I should query something like names(REPLSecretPlace, all=true) to get all the variables defined in the current interactive repl?

Or perhaps I should bind the repl Main with the server Main in some way? If so, I have no idea how to do that and any suggestion would be highly appreciated.

2 Likes

Just to surface the obvious question: Why REPL for remote rather than Pluto? (I’m sure you have your reasons—just hoping to get it settled in any one else’s head.)

Well, for the sake of generality. :slight_smile:

I think you can potentially create any simple frontend to connect to a Julia backend through very simple protocols while enjoying an interactive experience at the same time.

You would not be constrained to use off-the-shelf Pluto, Jupyter, and so on, but you can create your own.

For the record, I created a frontend using Vim9 and the only missing part is accessing variables defined in the REPL space, as discussed in the thread.

But regardless of the specific usage, in a more general setting: not being able to access the REPL namespace from a subprocess is just… strange.

1 Like

FYI: This seems to work:

function get_repl_variables(conn, id, params)
  println(@eval Main names(Main, all=true))
end

Is should be because I am using @eval I am forcing runtime evaluation whereas using just names(Main, all=true) happens at compile time.

1 Like

My gut reaction would be a task being stuck in an older world age, specifically the initial state of methods and global variables where A didn’t exist. Try the internal Base.tls_world_age() in get_repl_variables. If those UInts differ, @invokelatest can let the task access the latest world age, but there is probably a better way of handling a remote REPL without messing with world age.

EDIT: Looking at it again, I’m more certain that’s the reason. @async start_server() calls a function that calls start_server, which loops calls of handle_client that calls get_repl_variables that accesses Main. The top-level call’s duration is stuck in a world age, including Main’s state. From the docs:

In addition, each Task stores a local world age that determines which modifications to the global binding and method tables are currently visible to the running task. The world age of the running task will never exceed the global world age counter, but may run arbitrarily behind it.

The subsequent code example of a stuck world age is in a begin block, not a function call or any of the functions and macros documented in Asynchronous Programming, so I’m not sure exactly what a Task means there.

@eval executes an expression in the global scope where the world age is updated, explaining your workaround. @invokelatest should work too, but it’s documented with a subtle note that may affect higher-order functions, f meaning the callable:

If f is a global, it will be resolved consistently in the (latest) world as the call target. However, all other arguments (as well as f itself if it is not a literal global) will be evaluated in the current world age.

2 Likes

This.

I am new in Julia, and I was not aware of the world age mechanism (but now I am :smiley: ).
Nevertheless, at the very beginning I had the feeling that each task have its own Main scope and therefore I was looking for a way to bind one each other. But then, I read (and admittedly asked AI) that the Main is common, so the only option left to justify such a behaviior is that the repl and the server task were looking at different “snapshot” in time of the same Main.
Hence, I thought that in this case you may have two different snapshots of Main: one at compile time and one at runtime, where the server task is looking at the former and the repl at the latter. I didn’t land too far away. :slight_smile:

Now, I learned that in reality you have N “snapshots” of Main and the sever task was simply pointing to an old snapshot. Hence, I think that the utilization of @invokelatest is the most accurate way to solve the issue given the mental model of time-spaced Main snapshots. Yet, for this use-case, and by following a different mental model, @eval is not wrong either given that its purpose is for runtime evaluation, and in this case @eval would most likely pick the latest and greatest Main shapshot.

Note that here, the Main used by the server task is a snapshot at compile time (note that at runtime it enters an infinite loop, so its Main snapshot must be the one at compile time) and therefore a names(Main, all=true) would return what is defined in the Main snapshot at compile time.

Does it make sense?

Nevertheless, I cannot test if @invokelatest work at the moment, but I will give it a shot this evening (I live in central Europe). But the reproducible code is at the top of this thread and you can just copy/paste: if someone wants to give it a shot in the meantime, please go ahead! :slight_smile:

1 Like

I have finally found some time to test the solution with @invokelatest and it works fine.