Skip to content

Commit 2ede49e

Browse files
committed
reimplemented callstack - making it crossplatform
1 parent 76addba commit 2ede49e

File tree

5 files changed

+121
-82
lines changed

5 files changed

+121
-82
lines changed
Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { TryCatchFinallyHooksBuilder } from "../TryCatchFinallyHooks"
1+
import { TryCatchFinallyHooksBuilder } from "./TryCatchFinallyHooks"
22
import { callStack } from './callStack'
33
import { AsyncResource, AsyncLocalStorage } from 'node:async_hooks'
44

@@ -7,13 +7,13 @@ function createTrack(log:any){
77
.add(callStack)
88
.add({
99
onTry(ctx) {
10-
log("onTry", [...ctx.callstack.actions.map(c=>c.args.name), ctx.args.name].join('/'))
10+
log("onTry", [...ctx.callstack.map(c=>c.args.name), ctx.args.name].join('/'))
1111
return {
1212
onFinally() {
13-
log("onFinally",[...ctx.callstack.actions.map(c=>c.args.name), ctx.args.name].join('/'))
13+
log("onFinally",[...ctx.callstack.map(c=>c.args.name), ctx.args.name].join('/'))
1414
},
1515
onCatch() {
16-
log("onCatch",[...ctx.callstack.actions.map(c=>c.args.name), ctx.args.name].join('/'))
16+
log("onCatch",[...ctx.callstack.map(c=>c.args.name), ctx.args.name].join('/'))
1717
}
1818
}
1919
}
@@ -22,7 +22,8 @@ function createTrack(log:any){
2222

2323

2424
test("callstack", ()=>{
25-
const log = jest.fn((...args:any[])=>console.log(...args))
25+
//const log = jest.fn((...args:any[])=>console.log(...args))
26+
const log = jest.fn()
2627
const track = createTrack(log)
2728

2829
const myChildFunc = jest.fn(track.asFunctionWrapper({name:"MyChildFunc"})((a:number,b:number)=>{
@@ -48,7 +49,8 @@ test("callstack", ()=>{
4849

4950
test("callstack async",async ()=>{
5051
const amountOfParallels = 2
51-
const log = jest.fn((...args:any[])=>console.log("async",...args))
52+
//const log = jest.fn((...args:any[])=>console.log("async",...args))
53+
const log = jest.fn()
5254
const track = createTrack(log)
5355

5456
const asyncStr = new AsyncLocalStorage<any>()
@@ -84,20 +86,23 @@ test("callstack async",async ()=>{
8486

8587
function delay(ms:number){return new Promise(r=>setTimeout(r,ms))}
8688

87-
test.only("callstack recursive fiboncci", ()=>{
88-
const log = jest.fn((...args:any[])=>console.log(...args))
89+
test("callstack recursive sync", ()=>{
90+
//const log = jest.fn((...args:any[])=>console.log(...args))
91+
const log = jest.fn()
8992
const track = createTrack(log)
9093

91-
const fib = jest.fn(track.asFunctionWrapper({name:"fib"})(function(n:number):number{
94+
const myRecFunc = jest.fn(track.asFunctionWrapper({name:"myRecFunc"})(function(n:number):number{
9295
if(n<=1) return 1
93-
return fib(n-1)+fib(n-2)
96+
return 1+myRecFunc(n-1)
9497
}))
9598

96-
const actRes =fib(3)
97-
const expRes = 1+ 1+2 + 1+2+3
99+
const actRes =myRecFunc(3)
100+
const expRes = 1 + 1 + 1
98101
expect(actRes).toBe(expRes)
99-
expect(fib).toHaveBeenCalledTimes(1+2+3)
100-
expect(log).toHaveBeenCalledTimes((1+2+3)*2)
102+
expect(myRecFunc).toHaveBeenCalledTimes(3)
103+
expect(log).toHaveBeenCalledTimes((3)*2)
104+
expect(log).toHaveBeenNthCalledWith(1, "onTry", "myRecFunc")
105+
expect(log).toHaveBeenNthCalledWith(2, "onTry", "myRecFunc/myRecFunc")
101106
})
102107

103108
// test.skip('async hooks',async ()=>{

src/callStack.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { ITryCatchFinallyHook } from "./TryCatchFinallyHooks";
2+
3+
/**
4+
* This is a callstack tracker, which provides Stack of Called Function Context - works both for async and sync call stacks
5+
* Works both on node and browser
6+
*
7+
* How it works:
8+
* Each context (action invocation) is assigned with unique id (callId)
9+
* We track global list of contexts objects that are currently active (between try and finally).
10+
* During onTry Context.func (that is about to be invoked) is replaced with a special function wrapper that has assigned unique name `callstack_callId:<context.callId>`.
11+
* Now those function names in Error.captureCallstack() can be associated context.callstack.callId via list of context objects that are currently active.
12+
*
13+
* If you don't see actions in your callstack, increase Error.stackTraceLimit
14+
*/
15+
16+
const callPrefix = 'callstack_callId:'
17+
18+
export type CallstackContext = { args: { name?: string;}, name:string, id: string, callstack: CallstackContext[] };
19+
20+
export const callStack: ITryCatchFinallyHook<CallstackContext> = {
21+
onTry(ctx) {
22+
if(!ctx.name)
23+
ctx.name = ctx.args.name || ctx.func.name || '<anonymous>'
24+
25+
if(!ctx.id)
26+
ctx.id = Math.random().toString(16).slice(2)
27+
getOrUpdateAllActiveContexts(actions=>[...actions, ctx])
28+
29+
30+
const origFunc = ctx.func
31+
const callstackCallWrapper = function (this:any,...args:any) { return origFunc.apply(this, args) }
32+
Object.defineProperty(callstackCallWrapper, 'name', {value:callPrefix+ctx.id})
33+
ctx.func = callstackCallWrapper
34+
35+
Object.defineProperty(ctx, 'callstack', {
36+
get() {
37+
const contextsMap = new Map(getOrUpdateAllActiveContexts().map(ctx=>[ctx.id, ctx]))
38+
const actionCallIdStack = getActionCallIdsStack()
39+
return actionCallIdStack.map(ctxId=>contextsMap.get(ctxId)!)
40+
}
41+
});
42+
43+
return {
44+
onFinally() {
45+
getOrUpdateAllActiveContexts(ctxs=>ctxs.filter(c=>c!=ctx))
46+
},
47+
lastInQueue: true
48+
};
49+
}
50+
};
51+
52+
function prepareActionStackTrace(error: Error, stack: NodeJS.CallSite[]): string[] {
53+
return stack
54+
.map(s=>s.getFunctionName()!)
55+
.filter(s=>s?.startsWith(callPrefix))
56+
.map(s=>s.slice(callPrefix.length))
57+
}
58+
59+
function getActionCallIdsStack():string[]
60+
{
61+
const prepStackOld = Error.prepareStackTrace
62+
try
63+
{
64+
Error.prepareStackTrace = prepareActionStackTrace
65+
const resObj: { stack?: string[]} = {}
66+
Error.captureStackTrace(resObj)
67+
return resObj.stack as string[]
68+
}
69+
finally{
70+
Error.prepareStackTrace = prepStackOld
71+
}
72+
}
73+
74+
75+
const allActiveContexts// mimicking AsyncLocalStorage
76+
= {
77+
_items:undefined as CallstackContext[]|undefined,
78+
getStore():CallstackContext[]|undefined{
79+
return [...this._items||[]]
80+
},
81+
enterWith(items:CallstackContext[]){
82+
this._items = items||[]
83+
}
84+
}
85+
86+
function getOrUpdateAllActiveContexts(update?:(old:CallstackContext[])=>undefined|CallstackContext[]):CallstackContext[]{
87+
const store = [...allActiveContexts.getStore()||[]]
88+
if(update)
89+
{
90+
const res = update(store)
91+
if(res) allActiveContexts.enterWith(res);
92+
return res || store
93+
}
94+
return store
95+
}

src/example/tracker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { callStack } from "../node/callStack";
1+
import { callStack } from "../callStack";
22
import { logOnFinally } from "./logOnFinally";
33
import { measureDuration } from "../measureDuration";
44
import { TryCatchFinallyHooksBuilder, ContextOf } from "../TryCatchFinallyHooks";

src/node/callStack.ts

Lines changed: 0 additions & 62 deletions
This file was deleted.

src/tryCatchFinally.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
export type FunctionContext<TFunc extends (...args: any[]) => any = (...args: any[]) => any> = {
1+
export type FunctionContext<TFunc extends (this:any, ...args: any[]) => any = (this:any,...args: any[]) => any> = {
22
func: TFunc
33
funcWrapper: TFunc
44
funcArgs: Parameters<TFunc>
5+
funcThis?: ThisParameterType<TFunc>
56
funcOutcome?: FunctionExecutionOutcome<TFunc>
67
}
78

@@ -16,14 +17,14 @@ export type FunctionInterceptors<TFunc extends (this:any, ...args: any[]) => any
1617
}
1718

1819
export function createTryCatchFinally<TFunc extends (this:any, ...args: any[]) => any, TContext extends {}>(
19-
fn: TFunc,
20+
func: TFunc,
2021
interceptors: FunctionInterceptors<TFunc, TContext>
2122
): TFunc {
2223
type Ctx = FunctionContext<TFunc> & TContext
2324
let funcWrapper:TFunc = function tryCatchFinallyWrapper(...funcArgs) {
2425
let isAsync = false
2526
let funcRes: ReturnType<TFunc>;
26-
let ctx: Ctx = <FunctionContext<TFunc>> { func: fn, funcArgs, funcWrapper } as any
27+
let ctx: Ctx = <FunctionContext<TFunc>> { func, funcArgs, funcWrapper, funcThis: this } as any
2728
let tryRes: ReturnType<NonNullable<typeof interceptors.onTry>> = {} as any;
2829
let onCatch = 'onCatch' in interceptors ? interceptors.onCatch : undefined;
2930
let onFinally = 'onFinally' in interceptors ? interceptors.onFinally : undefined;
@@ -35,7 +36,7 @@ export function createTryCatchFinally<TFunc extends (this:any, ...args: any[]) =
3536
if('onCatch' in tryRes) onCatch = tryRes.onCatch
3637
if('onFinally' in tryRes) onFinally = tryRes.onFinally
3738

38-
funcRes = ctx.func?.apply(this,funcArgs);
39+
funcRes = ctx.func?.apply(ctx.funcThis,ctx.funcArgs);
3940
isAsync = isPromise(funcRes);
4041
ctx.funcOutcome = { type: 'success', result: funcRes as Awaited<ReturnType<TFunc>> };
4142
if (!isAsync) {
@@ -77,7 +78,7 @@ export function createTryCatchFinally<TFunc extends (this:any, ...args: any[]) =
7778
}
7879
}
7980
} as TFunc;
80-
Object.defineProperty(funcWrapper, 'name', { value: funcWrapper.name+'_'+(fn.name||'anonymous') });
81+
Object.defineProperty(funcWrapper, 'name', { value: funcWrapper.name+'_'+(func.name||'anonymous') });
8182
return funcWrapper
8283
}
8384

0 commit comments

Comments
 (0)