It seems to me that running one SCI script at a time in each node.js worker thread is a better way to enforce timeout than node.js vm module. node.js vm module launches a new thread when you specify timeout. https://nodejs.org/api/vm.html says > Using the timeout or breakOnSigint options will result in new event loops and corresponding threads being started, which have a non-zero performance overhead. This means it is more expensive than just maintaining a node.js worker thread pool where each thread runs one SCI script at a time. I can enforce timeout by terminating a worker thread. It seems worker.terminate() is not deprecated unlike JVM's Thread.stop(). A worker thread can enforce its own memory consumption limit. Although out-of-memory error can take down the entire process, it can also just take down only a worker thread. To limit the blast radius, you can limit the number of threads in each process. node.js worker threads are a better substrate for untrusted SCI scripts. In my mind, I would have a dynamically resizing process pool where each process has a few threads, and each thread processes one SCI script at a time.
I don't know much about killing threads in node.js, but depending on which functions/macros you want expose you can do the sandboxing in Sci. I got pretty far with this. I think there is one last hurdle; recursion of functions. I think this could be fixed when this issue is resolved https://github.com/babashka/sci/issues/1002
I'm talking about limiting execution time and memory consumption outside SCI API sandboxing.
SCI itself can't enforce timeout or memory limit.
You can run SCI scripts in isolated-vm or a worker thread.
worker.terminate() can reliably kill an SCI script stuck in a CPU loop.
I don't think I can realistically find a way to limit execution time and memory consumption inside SCI.
If you control loops and recursion you can control execution time, no?
If I want to allow recursion?
So https://github.com/babashka/sci/issues/1002 is about blocking the underlying fn* , but you would implement your own version of fn that has a counter of recursion. So you would allow it until x times or x seconds
There is also letfn which can lead to recursion.
Yeah I think you can do the same as with fn, but i didn't try yet. The problem is that fn* cannot be denied at the moment
Are you sure that you can actually limit execution steps or execution time?
What about memory?
I can see that you are trying to use JVM.
Yeah I am not sure about memory, but you can override everything with Sci so you could measure the length of vectors or the reading of certain strings. So I'm not saying you get it for free, but I think it is possible For recursion, you have to wrap all generated fn's with an internal counter. And every so often you can check the elapsed time for instance
Maybe killing a thread is easier if possible
Stopping an asynchronous SCI script in node.js is not straightforward. I guess SCI scripts are going to be synchronous on JVM anyway.
Here is an example how you can control fn execution https://gist.github.com/jeroenvandijk/6586198b17a67a3863349f1f90cc6846#file-anti_recursion-clj-L44-L62
But as said before there are currently still backdoors via fn* , letfn* and some others
fn* has to be blocked? I am new to sci. I don't really know sci. I thought everything could be whitelisted.
Yeah it an implementation detail of Sci, so a user should normally not call it directly. But it gives access to function generation and thus also recursion
Can't you override fn* directly?
I thought this issue described it, but it is not complete https://github.com/babashka/sci/issues/1002 I have to check again what the problem was exactly, one moment
I think the better way to solve the issue is to enforce strict whitelist...?
It can add a strict mode to avoid breaking existing codebase?
So you have to replicate fn** (https://github.com/babashka/sci/blob/1f3a8cef69f9cc7c4a577084a948a2e749b0682e/src/sci/impl/fns.cljc#L200) but with the counter/timer built in. Currently this fn** depends on fn* (https://github.com/babashka/sci/blob/1f3a8cef69f9cc7c4a577084a948a2e749b0682e/src/sci/impl/fns.cljc#L205) so without fn* you can't generate functions with the current fn macro.
I'm not sure what would be a way around it
If fetching from database takes a long time, you might still need timeout.
> I think the better way to solve the issue is to enforce strict whitelist...?
Yeah if you can limit the functions and macros used that could be enough. But I'm guessing fn is used even in simple code
Why not just use a runtime environment that enforces timeout and memory limit?
You mean the node.js option? yeah i guess that is more convenient
I think node.js worker thread can reliably enforce timeout, but in the case of out-of-memory error, the entire process can easily crash.
Hmm I see, maybe another option is shelling out to a babashka process or something?
(a babashka process that runs sci, so not exposing bash etc)
https://github.com/nodejs/node/issues/34823 makes me think I should perhaps run one SCI script at a time in each node.js process in a dynamically resizing process pool.
I don't think babashka can run SCI scripts?
It does
How?
bb -e '(sci.core/eval-string ":hello")'The babashka doc didn't include sci..
Anyway, if babashka comes with SCI, then you can maintain a dynamically resizing pool of babashka processes. Each babashka process would run one SCI script at a time...
You can use babashka, node.js, or whatever if you run one SCI script at a time in each process inside a process pool.
Yeah, I don't know your usecase of course, but I guess that would work for some usecases
My supposed use case is server-side hiccup/HTML rendering from user SCI scripts. Basically, a more flexible version of shopify liquid template language.
You can enforce timeout by killing a process. Memory limit is enforced by the process itself or the OS.
Do you still need letfn and loop for that?
I don't know... Iterating over products may need a loop of some kind.
Yeah maybe for ?
But no real recursion?
Yes?
But, I want people to define functions for composable hiccup templates...
Once I introduce functions, recursion becomes possible.
Yeah, but you could block it. For templating sounds like you normally wouldn't need it
If you block recursion the problem becomes easier
A function with parameters is only natural for flexible hiccup templating.
I don't know how else I would do that...
Can you give an example of what you mean?
Also, hiccup templates can also potentially recurse... if I allow composite hiccup tags?
(defn abc-section
[name]
[:article "..." (more-function ...)])As I wrote above, I'm new to clojure ecosystem. I still have to learn almost everything from scratch.
All I have for now is some vague intuition.
If you find any good way to prevent recursion, let me know. I'd very much like to stay on one JVM process if possible.
Yeah recursion is tricky. There are many ways to do it in normal clojure, here are some https://gist.github.com/jeroenvandijk/27ad5a6ddafb53742970053be90a13c2
I looked into this a while ago, and from what I remember that fn* issue is the blocker to control it. But maybe there is another way, I have to revisit it maybe
For now, I think if the rendering via sci in babashka is performant enough for you that is probably the easiest
The babashka documentation doesn't include sci namespace, but if it actually has SCI, I can use babashka, node.js threads, or node.js processes for limiting time and memory.
It's there since 2023 https://github.com/babashka/babashka/blob/b3329c399755b3823b4a00a4f37e3d68b71a6ae4/CHANGELOG.md#13183-2023-08-22
But, it seems babashka is somewhat restricted in what it can do. node.js threads and node.js processes might be a better option for a richer environment.
If you only need to modify fn, then you should probably ask for a strict whitelist mode. That way, you can inject a modified fn into SCI without allowing fn* in SCI scripts.
The problem is that sci doesn't differentiate between source-level forms and expanded forms.
Yeah I think it can be fixed https://clojurians.slack.com/archives/C015LCR9MHD/p1757799521928179?thread_ts=1757799082.721059&cid=C015LCR9MHD
It's fixable, but it hasn't been fixed. That means I'm going to use babashka processes, node.js threads, or node.js processes. Or, you are going to have to pay him a lot if you want it to be fixed quickly. He's available for commercial service. Or, you are going to have to fix it yourself.
Sure 🙂
If you find something new, you can create an issue or comment on the issue I mentioned
I still think it's a better use of your time to find a suitable runtime environment for imposing time limit and memory limit on SCI scripts. In my case, as I wrote above, the simplest options are babashka processes, node.js threads, and node.js processes. JVM processes are available, but they consume more memory and are slower to boot up.
Even with fn trick, imposing memory limit and time limit can be a hard problem.
According to my research, ruby puma web server uses multi process + multi thread architecture and runs entirely "synchronous" code unlike node.js which runs asynchronous code in one thread. Each process in puma process pool has 3 ~ 5 threads. Each thread in a puma process handles one request at a time synchronously. This obviously requires a lot more RAM than just having multiple threads in one process, but it will limit the blast radius of a malicious/problematic SCI script to 3 ~ 5 SCI jobs. Additional hardware you have to purchase costs a lot less than extra developer time required for a more complex architecture.
Asynchronous code is difficult to control.
If I apply puma architecture in node.js SCI server-side renderers, node.js would still consume less memory than ruby puma.
Developer time is a lot more expensive than extra RAM. Time is money.
Yeah I guess it depends on the problem, but if you can solve it with extra RAM that's great!
What problem are you trying to solve that requires limiting execution steps and minimal RAM usage?
I haven't been working on it lately, but my intention was to have a low latency and high performance web environment. Having the sandbox in Sci directly makes it a lot leaner and efficient. But if you have a specific use case in mind with specific requirements, maybe that is not necessary at all!
I want users to write server-side HTML/hiccup rendering scripts in SCI.
A more flexible version of shopify liquid template language.
I think the need to limit time and memory against untrusted sandboxes leads to puma-style architecture for simplicity.
Maybe, but it is hard to compare typical ruby performance with clojure. With shared memory and very good concurrency primitives etc Clojure has a lot of benefits
Puma probably won't kill my business.
That you can potentially benefit from with Sci But until there is a proper sandbox you might still have to go that puma route
At this point, I need to produce something quickly. If my business grows, then I can probably pay borkdude to implement robust limitation on execution steps or execution time or something.
Yeah of course, choose the most pragmatic solution
Time is money.
My current iteration of idea is to run one "synchronous" SCI script at a time in one node.js process in a process pool and limit the amount of heap space for each process to 32MB. Timeout is enforced by killing a process. This is easier to reason about and still consumes less memory than ruby puma workers. I'm just going to have to purchase simplicity with more hardware.
Is it possible to dump SCI env state to some blob and then recreate env with all defs/defn from it?
Maybe you can serialize the state somehow? https://github.com/babashka/sci?tab=readme-ov-file#state
Or, do you want to fork the state?
@karol.wojcik what's the use case?
I will need both options. I’m creating RLM with embedded SCI as an interpreter. I’m querying an env with some query which spans multiple iterations and each iteration creates N vars. This env is a full context and I need to persist it, fork it if necessary, etc. Imagine a Claude Code, but vars instead of messages.
fork is possible
persist to disk not, unless you use host technology for it, or perhaps this works https://blog.redplanetlabs.com/2020/01/06/serializing-and-deserializing-clojure-fns-with-nippy/
Can you "manually" persist plain data? If you don't try to persist functions, things become easier. If everything you care to persist is EDN, you can persist it.
Are you trying to create a local agent or a shared backend? Just curious.
Local one. Tui/web, something alike Hermes, but on SCI for us Clojurists :)
Then, you don't need heavy restrictions that I'm considering for my shared backend.