Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

from ...__meta__ import __jsii_runtime_version__
import importlib.resources
from ..._stack_trace import capture_stack_trace
from ..._utils import memoized_property
from .base import BaseProvider
from ..types import (
Expand Down Expand Up @@ -321,6 +322,11 @@ def send(
self, request: KernelRequest, response_type: Type[KernelResponse]
) -> KernelResponse:
req_dict = self._serializer.unstructure(request)

stack_trace = capture_stack_trace()
if stack_trace is not None:
req_dict["$jsii.stacktrace"] = stack_trace

data = json.dumps(req_dict, default=jdefault).encode("utf8")

# Send our data, ensure that it is framed with a trailing \n
Expand Down
31 changes: 31 additions & 0 deletions packages/@jsii/python-runtime/src/jsii/_stack_trace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import os
import traceback
from typing import List, Optional

_INTERNAL_PREFIXES = (os.path.dirname(os.path.abspath(__file__)) + os.sep,)


def capture_stack_trace() -> Optional[List[List]]:
"""Capture the current Python stack trace, filtered to user frames only.

Returns a list of [file, line, column, function] tuples suitable for
sending over the jsii wire protocol, or None if stack trace capture
is disabled via the JSII_HOST_STACK_TRACES environment variable.

Frames are ordered most-recent-first (matching V8 Error.stack convention).
"""
if os.environ.get("JSII_HOST_STACK_TRACES", "").lower() not in ("1", "true", "yes"):
return None

frames = traceback.extract_stack()
result = []

for frame in frames:
if any(frame.filename.startswith(prefix) for prefix in _INTERNAL_PREFIXES):
continue
if frame.filename.startswith("<"):
continue
result.append([frame.filename, frame.lineno, 0, frame.name])

result.reverse()
return result if result else None
29 changes: 29 additions & 0 deletions packages/@jsii/python-runtime/tests/test_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,35 @@ def implement_me(
assert OverrideMe.call_abstract(Overridden())


class TestHostStackTrace:
def test_stack_trace_is_passed_to_kernel(self, monkeypatch):
monkeypatch.setenv("JSII_HOST_STACK_TRACES", "1")
from jsii_calc import HostStackTraceReader

trace = HostStackTraceReader.captured_trace()
assert trace is not None
assert len(trace) > 0
# Each frame should be [file, line, column, function]
for frame in trace:
assert len(frame) == 4

def test_stack_trace_contains_this_file(self, monkeypatch):
monkeypatch.setenv("JSII_HOST_STACK_TRACES", "1")
from jsii_calc import HostStackTraceReader

trace = HostStackTraceReader.captured_trace()
assert trace is not None
files = [frame[0] for frame in trace]
assert any("test_python" in f for f in files)

def test_stack_trace_not_passed_when_disabled(self, monkeypatch):
monkeypatch.delenv("JSII_HOST_STACK_TRACES", raising=False)
from jsii_calc import HostStackTraceReader

trace = HostStackTraceReader.captured_trace()
assert trace is None


def find_struct_bases(x):
ret = []
seen = set([])
Expand Down
72 changes: 72 additions & 0 deletions packages/@jsii/python-runtime/tests/test_stack_trace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import os
import pytest

from jsii._stack_trace import capture_stack_trace


@pytest.fixture(autouse=True)
def disable_stack_traces(monkeypatch):
"""Ensure each test starts with stack traces disabled."""
monkeypatch.delenv("JSII_HOST_STACK_TRACES", raising=False)


class TestCaptureStackTrace:
def test_returns_none_when_disabled(self):
assert capture_stack_trace() is None

def test_returns_frames_when_enabled(self, monkeypatch):
monkeypatch.setenv("JSII_HOST_STACK_TRACES", "1")
result = capture_stack_trace()
assert result is not None
assert len(result) > 0

def test_frame_format(self, monkeypatch):
monkeypatch.setenv("JSII_HOST_STACK_TRACES", "true")
result = capture_stack_trace()
assert result is not None
for frame in result:
assert len(frame) == 4
file, line, col, fn = frame
assert isinstance(file, str)
assert isinstance(line, int)
assert isinstance(col, int)
assert isinstance(fn, str)
assert col == 0

def test_most_recent_frame_first(self, monkeypatch):
monkeypatch.setenv("JSII_HOST_STACK_TRACES", "yes")

def outer():
def inner():
return capture_stack_trace()

return inner()

result = outer()
assert result is not None
function_names = [frame[3] for frame in result]
inner_idx = function_names.index("inner")
outer_idx = function_names.index("outer")
assert inner_idx < outer_idx

def test_filters_jsii_internal_frames(self, monkeypatch):
monkeypatch.setenv("JSII_HOST_STACK_TRACES", "1")
result = capture_stack_trace()
assert result is not None
jsii_dir = os.path.dirname(os.path.abspath(os.path.dirname(__file__)))
for frame in result:
assert not frame[0].startswith(os.path.join(jsii_dir, "jsii") + os.sep)

def test_filters_synthetic_frames(self, monkeypatch):
monkeypatch.setenv("JSII_HOST_STACK_TRACES", "1")
result = capture_stack_trace()
assert result is not None
for frame in result:
assert not frame[0].startswith("<")

def test_this_file_appears_in_frames(self, monkeypatch):
monkeypatch.setenv("JSII_HOST_STACK_TRACES", "1")
result = capture_stack_trace()
assert result is not None
files = [frame[0] for frame in result]
assert any(__file__ in f for f in files)
57 changes: 57 additions & 0 deletions packages/@jsii/runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,63 @@ See [STDIN/STDOUT protocol](./lib/in-out.ts) and [@jsii/kernel
API](https://github.com/aws/jsii/blob/main/packages/@jsii/kernel/lib/api.ts)
for details.

## Host Stack Traces

When using jsii from a non-JavaScript language (Python, Java, Go, .NET), stack
traces captured inside the kernel refer to JavaScript frames, which are not
useful to end users. The **host stack trace** feature allows language runtimes to
capture a stack trace on the host side and send it to the kernel, so that
downstream consumers (such as the AWS CDK) can report meaningful traces in the
user's language.

### Enabling

Set the environment variable `JSII_HOST_STACK_TRACES=1` to opt in. When
disabled (the default), no stack traces are captured and no additional data is
sent over the wire.

### Wire protocol

When enabled, the host runtime attaches a `$jsii.stacktrace` field to any
request sent to the kernel:

```json
{
"api": "create",
"fqn": "aws-cdk-lib.Stack",
"args": [],
"$jsii.stacktrace": [
["my_app/my_stack.py", 42, 0, "MyStack.__init__"],
["app.py", 12, 0, "<module>"]
]
}
```

Each frame is a tuple of `[file, line, column, function]`:

| Field | Type | Description |
|----------|--------|-------------------------------------------------------|
| file | string | Source file path (relative or absolute) |
| line | number | 1-indexed line number |
| column | number | 0-indexed column (0 if unavailable) |
| function | string | Qualified function name (e.g. `MyStack.__init__`) |

Frames are ordered most-recent-first (matching V8 `Error.stack` convention).

### Kernel-side contract

The kernel extracts the `$jsii.stacktrace` field and stores it in a well-known
global before dispatching the request:

```js
globalThis[Symbol.for('jsii.context.hostStackTrace')]
```

This global is set before the kernel method executes and cleared immediately
after. JavaScript code running inside the kernel (e.g., CDK construct libraries)
can read this global to obtain the host-side stack trace without depending on any
jsii package.

## License

__jsii__ is distributed under the
Expand Down
10 changes: 10 additions & 0 deletions packages/@jsii/runtime/lib/host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { EventEmitter } from 'events';

import { Input, IInputOutput } from './in-out';

const HOST_STACK_TRACE_SYMBOL = Symbol.for('jsii.context.hostStackTrace');

export class KernelHost {
private readonly kernel = new Kernel(this.callbackHandler.bind(this));
private readonly eventEmitter = new EventEmitter();
Expand Down Expand Up @@ -123,6 +125,9 @@ export class KernelHost {
const apiReq = req;
const fn = this.findApi(apiReq.api);

const hostTrace = (req as any)['$jsii.stacktrace'];
(global as any)[HOST_STACK_TRACE_SYMBOL] = hostTrace ?? undefined;

try {
const ret = fn.call(this.kernel, req);

Expand All @@ -139,6 +144,7 @@ export class KernelHost {
this.debug('processing pending promises before responding');

setImmediate(() => {
(global as any)[HOST_STACK_TRACE_SYMBOL] = undefined;
this.writeOkay(ret);
next();
});
Expand All @@ -157,11 +163,13 @@ export class KernelHost {
promise
.then((val) => {
this.debug('promise succeeded:', val);
(global as any)[HOST_STACK_TRACE_SYMBOL] = undefined;
this.writeOkay(val);
next();
})
.catch((e) => {
this.debug('promise failed:', e);
(global as any)[HOST_STACK_TRACE_SYMBOL] = undefined;
this.writeError(e);
next();
});
Expand All @@ -172,6 +180,8 @@ export class KernelHost {
this.writeOkay(ret);
} catch (e: any) {
this.writeError(e);
} finally {
(global as any)[HOST_STACK_TRACE_SYMBOL] = undefined;
}

// indicate this request was processed (synchronously).
Expand Down
74 changes: 74 additions & 0 deletions packages/@jsii/runtime/test/host-stack-trace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { KernelHost, IInputOutput, Input, Output } from '../lib';

const HOST_STACK_TRACE_SYMBOL = Symbol.for('jsii.context.hostStackTrace');

beforeEach(() => {
(global as any)[HOST_STACK_TRACE_SYMBOL] = undefined;
});

test('sets host stack trace on global during request processing', () => {
const trace = [
['my_stack.py', 42, 0, 'MyStack.__init__'],
['app.py', 12, 0, '<module>'],
];

let capturedTrace: any = 'NOT_SET';

const inout = new SpyInputOutput(
[
{
api: 'stats',
'$jsii.stacktrace': trace,
} as any,
],
() => {
// By the time we write the response, the trace should have been set
// during processing. We capture it from the kernel's perspective via
// a spy on the output.
capturedTrace = (global as any)[HOST_STACK_TRACE_SYMBOL];
expect(capturedTrace).toEqual(trace);
},
);

const host = new KernelHost(inout, { noStack: true });
return new Promise<void>((ok) => {
host.once('exit', () => {
// After processing completes, the trace should be cleared
expect((global as any)[HOST_STACK_TRACE_SYMBOL]).toBeUndefined();
ok();
});
host.run();
});
});

test('host stack trace is undefined when not provided in request', () => {
const inout = new SpyInputOutput([{ api: 'stats' } as any]);

const host = new KernelHost(inout, { noStack: true });
return new Promise<void>((ok) => {
host.once('exit', () => {
expect((global as any)[HOST_STACK_TRACE_SYMBOL]).toBeUndefined();
ok();
});
host.run();
});
});

class SpyInputOutput implements IInputOutput {
private readonly inputCommands: Input[];

public constructor(
inputCommands: Input[],
private readonly onWrite?: (output: Output) => void,
) {
this.inputCommands = [...inputCommands].reverse();
}

public read(): Input | undefined {
return this.inputCommands.pop();
}

public write(obj: Output): void {
this.onWrite?.(obj);
}
}
20 changes: 20 additions & 0 deletions packages/jsii-calc/lib/host-stack-trace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const HOST_STACK_TRACE_SYMBOL = Symbol.for('jsii.context.hostStackTrace');

/**
* A class that exposes the host stack trace provided by the jsii runtime.
*
* This is used for integration testing to verify that stack traces captured
* in the host language (Python, Java, Go, .NET) are correctly transmitted
* to the kernel.
*/
export class HostStackTraceReader {
/**
* Returns the current host stack trace, if one was provided by the runtime.
*
* Each frame is a tuple of [file, line, column, function].
* Returns undefined if no host stack trace is available.
*/
public static capturedTrace(): any {
return (globalThis as any)[HOST_STACK_TRACE_SYMBOL];
}
}
1 change: 1 addition & 0 deletions packages/jsii-calc/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ export * as intersection from './intersection';
export * as homonymousForwardReferences from './homonymous';
export * as pascalCaseName from './pascal-case-name';
export * as covariantOverrides from './covariant-overrides';
export * from './host-stack-trace';
Loading
Loading