Skip to content

Commit c832f31

Browse files
committed
wip
1 parent d808b6f commit c832f31

File tree

5 files changed

+140
-64
lines changed

5 files changed

+140
-64
lines changed

doc/issues/stacktrace-at-point-of-execution.md

Lines changed: 58 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -31,88 +31,99 @@ When catching exceptions with `^:sci/error`, you only get the immediate error fr
3131

3232
This is because the stack is built during exception **propagation**. When you catch early with `^:sci/error`, propagation stops and you only get the immediate frame.
3333

34-
## Solution
34+
## Why Analysis-Time Only Doesn't Work
3535

36-
Maintain a runtime call stack that gets pushed/popped as functions are entered/exited. The **infrastructure** (push/pop code) is generated at analysis time, but executes at runtime - exactly like the existing exception handling try/catch wrappers.
36+
Each node gets its stack frame embedded at analysis time. However, **function bodies are analyzed once at definition time**, not re-analyzed at each call site.
3737

38-
## How Exception Stack Works (for reference)
38+
Example:
39+
```clojure
40+
(defn inner [] (current-stacktrace)) ;; analyzed here - don't know who will call inner
41+
(defn outer [] (inner)) ;; creates call node, but inner's body already analyzed
42+
(outer)
43+
```
3944

40-
Currently, when an exception occurs:
41-
1. Each function call is wrapped in try/catch (set up at analysis time)
42-
2. When exception is thrown, each wrapper catches it, adds its frame via `rethrow-with-location-of-node`, and rethrows
43-
3. Stack is built up as exception propagates
45+
When `(current-stacktrace)` inside `inner` is analyzed, `outer` doesn't exist yet. The runtime call chain (`outer` -> `inner`) can only be known at runtime.
4446

45-
Example output from `bb`:
46-
```
47-
user/inner - /tmp/test.bb:1:16
48-
user/inner - /tmp/test.bb:1:1
49-
user/outer - /tmp/test.bb:2:16
50-
user/outer - /tmp/test.bb:2:1
51-
user - /tmp/test.bb:3:1
52-
```
47+
## Solution: Runtime Tracking with Minimal Overhead
5348

54-
## Proposed Approach
49+
Each node already has its stack frame embedded at analysis time. We add push/pop at runtime, but with **near-zero overhead when not enabled**.
5550

56-
Similar to exception handling, but track the stack actively:
51+
### How It Works
5752

58-
1. **At analysis time**: Modify `gen-return-call` to wrap function calls with push/pop logic
59-
2. **At runtime**: Each call pushes its frame to a thread-local stack, executes, then pops
60-
3. **`current-stacktrace`**: Simply reads the current stack
53+
1. **`*call-stack*` dynamic var** (in utils.cljc): `nil` by default
54+
2. **Push/pop functions** only do work when `*call-stack*` is non-nil:
55+
```clojure
56+
(defn push-call-stack! [frame]
57+
(when *call-stack* ;; nil check = near-zero overhead
58+
(vswap! *call-stack* conj frame)))
59+
```
60+
3. **To enable tracking**: bind `*call-stack*` to `(volatile! [])`
61+
4. **`current-stacktrace`**: reads from the volatile
6162

6263
### Implementation
6364

64-
In `gen-return-call` (analyzer.cljc), wrap calls like:
65+
**1. Modify `gen-return-call`** (analyzer.cljc) - add push/pop around calls:
6566

6667
```clojure
67-
;; Pseudocode for generated node
68-
(let [stack-frame {...}] ;; computed at analysis time
69-
(push-frame! stack-frame)
68+
;; Inside ->Node body (runs at runtime):
69+
(do
70+
(utils/push-call-stack! stack) ;; stack is the node's frame, set at analysis time
7071
(try
7172
(f arg0 arg1 ...)
73+
(catch Throwable e
74+
(rethrow-with-location-of-node ctx bindings e this))
7275
(finally
73-
(pop-frame!))))
76+
(utils/pop-call-stack!))))
7477
```
7578

76-
The stack would be stored in a dynamic var or thread-local, similar to how `*in-try*` works.
77-
78-
### API
79+
**2. `current-stacktrace`** (core.cljc):
7980

8081
```clojure
81-
;; Host-level API
82-
(sci.core/current-stacktrace)
83-
;; => [{:ns user :name outer :file "script.clj" :line 2 :column 16}
84-
;; {:ns user :name foo :file "script.clj" :line 3 :column 1}]
85-
86-
;; Host exposes to users as they wish:
87-
(def ctx (sci/init {:namespaces
88-
{'debug {'stacktrace sci.core/current-stacktrace}}}))
82+
(defn current-stacktrace []
83+
(utils/get-call-stack))
8984
```
9085

9186
### Files to modify
9287

9388
| File | Changes |
9489
|------|---------|
9590
| `src/sci/impl/analyzer.cljc` | Modify `gen-return-call` to add push/pop around calls |
96-
| `src/sci/impl/utils.cljc` | Add dynamic var for current call stack, push/pop functions |
97-
| `src/sci/core.cljc` | Add `current-stacktrace` public function |
91+
| `src/sci/impl/utils.cljc` | ✅ Already done: `*call-stack*`, `push-call-stack!`, `pop-call-stack!`, `get-call-stack` |
92+
| `src/sci/core.cljc` | Update `current-stacktrace` to call `utils/get-call-stack` |
9893

99-
### Performance consideration
94+
### Performance
10095

101-
This adds overhead to every function call. Options:
102-
1. Always enabled (simpler, some overhead)
103-
2. Opt-in via context flag `{:track-stacktrace true}`
104-
3. Only enable when `current-stacktrace` is used in the code (analyzer detects usage)
96+
- **When not tracking** (`*call-stack*` is nil): just a nil check per function call
97+
- **When tracking**: push/pop on volatile per function call
98+
99+
### API Usage
100+
101+
```clojure
102+
;; Host enables tracking by binding *call-stack*:
103+
(binding [sci.impl.utils/*call-stack* (volatile! [])]
104+
(sci/eval-string* ctx "(outer)"))
105+
106+
;; Or expose via context for SCI code to use:
107+
(def ctx (sci/init {:namespaces
108+
{'debug {'stacktrace sci/current-stacktrace}}}))
109+
```
105110

106111
## Verification
107112

113+
Test in `test/sci/error_test.cljc`:
114+
108115
```clojure
109116
(deftest current-stacktrace-test
110-
(let [result (sci/eval-string "
117+
(testing "returns call chain at point of execution"
118+
(let [ctx (sci/init {:namespaces {'sci.core {'current-stacktrace sci/current-stacktrace}}})
119+
stacktrace (sci/binding [sci/file "test.clj"]
120+
(sci/eval-string* ctx "
111121
(defn inner [] (sci.core/current-stacktrace))
112122
(defn outer [] (inner))
113-
(outer)")]
114-
;; Should show outer as caller, not inner (inner is current fn)
115-
(is (= 'outer (:name (first result))))))
123+
(outer)"))]
124+
(is (= [{:ns 'user, :name 'inner, :file "test.clj", :line 2, :column 16}
125+
{:ns 'user, :name 'outer, :file "test.clj", :line 3, :column 15}]
126+
(mapv #(select-keys % [:ns :name :file :line :column]) stacktrace))))))
116127
```
117128

118129
Run: `script/test/jvm`

src/sci/core.cljc

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,14 @@
359359
[stacktrace]
360360
(cs/format-stacktrace stacktrace))
361361

362+
(defn current-stacktrace
363+
"Returns the current call stack at the point of invocation.
364+
Returns a list of stack frame maps with keys :ns, :name, :file, :line, :column.
365+
Only works when *call-stack* is bound (via binding)."
366+
[]
367+
(when-let [stack (utils/get-call-stack)]
368+
(cs/stacktrace (volatile! stack))))
369+
362370
(defn ns-name
363371
"Returns name of SCI ns as symbol."
364372
[sci-ns]

src/sci/impl/analyzer.cljc

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1342,30 +1342,48 @@
13421342
[i `(let ~binds
13431343
(if ~'wrap
13441344
(sci.impl.types/->Node
1345-
(try
1346-
((~'wrap ~'ctx ~'bindings ~'f)
1347-
~@(map (fn [j]
1348-
`(t/eval ~(symbol (str "arg" j)) ~'ctx ~'bindings))
1349-
(range i)))
1350-
(catch ~(macros/? :clj 'Throwable :cljs 'js/Error) e#
1351-
(rethrow-with-location-of-node ~'ctx ~'bindings e# ~'this)))
1345+
(do
1346+
(utils/push-call-stack! ~'stack)
1347+
(try
1348+
((~'wrap ~'ctx ~'bindings ~'f)
1349+
~@(map (fn [j]
1350+
`(t/eval ~(symbol (str "arg" j)) ~'ctx ~'bindings))
1351+
(range i)))
1352+
(catch ~(macros/? :clj 'Throwable :cljs 'js/Error) e#
1353+
(rethrow-with-location-of-node ~'ctx ~'bindings e# ~'this))
1354+
(finally
1355+
(utils/pop-call-stack!))))
13521356
~'stack)
13531357
(sci.impl.types/->Node
1354-
(try
1355-
(~'f
1356-
~@(map (fn [j]
1357-
`(t/eval ~(symbol (str "arg" j)) ~'ctx ~'bindings))
1358-
(range i)))
1359-
(catch ~(macros/? :clj 'Throwable :cljs 'js/Error) e#
1360-
(rethrow-with-location-of-node ~'ctx ~'bindings e# ~'this)))
1358+
(do
1359+
(utils/push-call-stack! ~'stack)
1360+
(try
1361+
(~'f
1362+
~@(map (fn [j]
1363+
`(t/eval ~(symbol (str "arg" j)) ~'ctx ~'bindings))
1364+
(range i)))
1365+
(catch ~(macros/? :clj 'Throwable :cljs 'js/Error) e#
1366+
(rethrow-with-location-of-node ~'ctx ~'bindings e# ~'this))
1367+
(finally
1368+
(utils/pop-call-stack!))))
13611369
~'stack)))])
13621370
let-bindings)
13631371
`[(if ~'wrap
13641372
(sci.impl.types/->Node
1365-
(eval/fn-call ~'ctx ~'bindings (~'wrap ~'ctx ~'bindings ~'f) ~'analyzed-children)
1373+
(do
1374+
(utils/push-call-stack! ~'stack)
1375+
(try
1376+
(eval/fn-call ~'ctx ~'bindings (~'wrap ~'ctx ~'bindings ~'f) ~'analyzed-children)
1377+
(finally
1378+
(utils/pop-call-stack!))))
13661379
~'stack)
13671380
(sci.impl.types/->Node
1368-
(eval/fn-call ~'ctx ~'bindings ~'f ~'analyzed-children)
1381+
(do
1382+
(utils/push-call-stack! ~'stack)
1383+
(try
1384+
(eval/fn-call ~'ctx ~'bindings ~'f ~'analyzed-children)
1385+
(finally
1386+
(utils/pop-call-stack!))))
13691387
~'stack))]))
13701388
tag# ~'(:tag (meta expr))]
13711389
(cond-> node#

src/sci/impl/utils.cljc

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,21 @@
273273
{:ns clojure-core-ns
274274
:doc "A sci.lang.Namespace object representing the current namespace."}))
275275

276+
;; Call stack for current-stacktrace
277+
(def ^:dynamic *call-stack* nil)
278+
279+
(defn push-call-stack! [frame]
280+
(when *call-stack*
281+
(vswap! *call-stack* conj frame)))
282+
283+
(defn pop-call-stack! []
284+
(when *call-stack*
285+
(vswap! *call-stack* pop)))
286+
287+
(defn get-call-stack []
288+
(when *call-stack*
289+
@*call-stack*))
290+
276291
(defn current-ns-name []
277292
(let [curr-ns @current-ns]
278293
(if (symbol? curr-ns) curr-ns (t/getName curr-ns))))

test/sci/error_test.cljc

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
(:require #?(:clj [sci.addons.future :as fut])
33
#?(:cljs [clojure.string :as str])
44
[clojure.test :as t :refer [deftest testing is]]
5-
[sci.core :as sci :refer [eval-string]]))
5+
[sci.core :as sci :refer [eval-string]]
6+
[sci.impl.utils :as utils]))
67

78
#?(:cljs (def Exception js/Error))
89

@@ -59,6 +60,29 @@
5960
"user - NO_SOURCE_PATH:1:34")
6061
formatted))))))
6162

63+
(deftest current-stacktrace-test
64+
(testing "returns call chain at point of execution"
65+
(let [ctx (sci/init {:namespaces {'sci.core {'current-stacktrace sci/current-stacktrace}}})
66+
stacktrace (binding [utils/*call-stack* (volatile! [])]
67+
(sci/binding [sci/file "test.clj"]
68+
(sci/eval-string* ctx "
69+
(defn inner [] (sci.core/current-stacktrace))
70+
(defn outer [] (inner))
71+
(outer)")))
72+
;; Remove nil :name entries from the result
73+
clean-frame (fn [m]
74+
(let [m (select-keys m [:ns :name :file :line :column])]
75+
(if (nil? (:name m))
76+
(dissoc m :name)
77+
m)))]
78+
;; Full call chain like exception stack trace
79+
(is (= [{:ns 'user, :name 'inner, :file "test.clj", :line 2, :column 16}
80+
{:ns 'user, :name 'inner, :file "test.clj", :line 2, :column 1}
81+
{:ns 'user, :name 'outer, :file "test.clj", :line 3, :column 16}
82+
{:ns 'user, :name 'outer, :file "test.clj", :line 3, :column 1}
83+
{:ns 'user, :file "test.clj", :line 4, :column 1}]
84+
(mapv clean-frame stacktrace))))))
85+
6286
#_(deftest locals-test
6387
(testing "defn does not introduce fn-named local binding"
6488
(let [locals

0 commit comments

Comments
 (0)