The pell debugger
Step through pell source running inside Oracle — breakpoints in .pell
files, a generated stub per pub fn, and fall-through to deployed
PL/SQL source when pell source runs out.
How it works
Oracle is the runtime, so the debugger drives the database’s own
debugging engine: DBMS_DEBUG_JDWP (the SQL Developer mechanism). The
database session connects out to the IDE over the JDWP protocol;
the IDE controls it with breakpoints/steps like any JVM target.
IntelliJ (JDI listener) ◄──JDWP── Oracle session
│ ▲
└── pell debug-target ─────────┘ (runs the stub block)
Line mapping is carried in the deployed source itself: debug builds
append -- @pell:<line> markers to every emitted statement
(pell deploy --debug), and pell srcmap reproduces the
pell ↔ PL/SQL line map from an in-memory rebuild — no sidecar files.
JDWP exposes PL/SQL units as synthetic classes
($Oracle.PackageBody.SCHEMA.NAME, $Oracle.Block.SCHEMA.<hash> for
the stub’s anonymous block). Frames in pell-compiled units map back to
.pell lines; frames in foreign PL/SQL (the logger runtime, hand-
written packages) fetch ALL_SOURCE via pell debug-source and open
read-only — stepping just keeps going.
One-time database setup
Ask a DBA to run, once per developer:
sqlplus system/<pwd>@host:1521/<pdb> @compiler/scripts/grant_debug.sql YOUR_USER
That grants DEBUG CONNECT SESSION (required), DEBUG ANY PROCEDURE
(optional — stepping into other schemas), and the JDWP network ACE
(only needed for the default jdwp transport). This is exactly the
privilege bar SQL Developer’s debugger has — Oracle treats session
debugging as a security boundary, and there is no grant-free path:
the SQL transport below checks the same DEBUG CONNECT SESSION
privilege.
Two transports
| jdwp (default) | sql (PELL_DEBUG_TRANSPORT=sql) |
|
|---|---|---|
| mechanism | DBMS_DEBUG_JDWP — DB connects back to the IDE | DBMS_DEBUG (Probe) — two outbound SQL sessions |
| network | needs an inbound path + JDWP ACE | outbound only — tunnels/NAT/no-ACL just work |
| grants | DEBUG CONNECT SESSION (+ ACE) | DEBUG CONNECT SESSION only |
| script (anon block) breakpoints | yes | no — step from the top instead |
| module breakpoints, stepping, locals, stack | yes | yes |
The sql transport is a full duplex JSON-lines protocol over the
pell debug-serve process’s stdio (websocket-style, minus the
socket): the IDE writes commands, events stream back. The target
session suspends at startup waiting for the debugger — a built-in
rendezvous, so breakpoints can’t be outrun.
# everything outbound — through the same tunnel as your SQL connection:
export PELL_DEBUG_TRANSPORT=sql
The engine is also usable standalone:
pell debug-serve script.pell # JSON commands on stdin, events on stdout
The docker harness and setup_example_schemas.sql already include
these grants for pell_test.
No DBA? Use trace mode
--trace is the zero-privilege fallback — line-by-line execution
tracing over DBMS_OUTPUT, needing nothing beyond what pell exec
already uses:
pell exec myscript.pell --trace # trace the script
pell deploy app/ --trace # instrument deployed packages too
Every statement prints [pell-trace] file.pell:line before it runs:
[pell-trace] trace_test.pell:2
INFO: greeting world
[pell-trace] trace_test.pell:3
hello, world
It composes with --debug (markers and trace lines don’t collide),
and traced packages stay traced for every caller until redeployed
without the flag.
Using it in IntelliJ
- Click the gutter icon on any
pub fn→ **Debug**. The plugin generates `.pell-debug/debug_ _ .pell` — a small exec script importing the module and calling the fn with placeholder args — and opens it. Edit the arguments (the file is yours; it's regenerated only if deleted). - Set breakpoints in the stub and/or the module’s
.pellsource. - The debug session: deploys the module with
--debug, starts the JDWP listener, runspell debug-target(which holds the block until breakpoints are armed), and stops at your lines. Variables, stack, step over/into/out all work;Runinstead ofDebugjust executes the stub.
Connection comes from PELL_DB_URL in the IDE’s environment.
Connect-back host
The database must be able to reach the IDE:
| database location | callback address used |
|---|---|
| docker on this machine | host.docker.internal (automatic) |
| remote host | the local interface’s IP (automatic) |
| anything unusual | set PELL_DEBUG_CALLBACK_HOST |
Through an SSH tunnel
If you reach the database through ssh -L 1521:localhost:1521 dbhost,
the auto-detection sees localhost and guesses wrong — and the
database can’t open a connection straight back to your machine anyway.
Give the JDWP connection the same treatment as the SQL connection: a
reverse tunnel.
# one ssh session carries both directions:
ssh -L 1521:localhost:1521 -R 5005:localhost:5005 user@dbhost
Then tell pell both halves — the IDE must listen on a FIXED port (the reverse tunnel needs one), and the database should connect to its own loopback, where sshd is listening:
export PELL_DEBUG_JDWP_PORT=5005 # pin the IDE's listener port
export PELL_DEBUG_CALLBACK_HOST=127.0.0.1 # "connect to yourself" — sshd forwards it
Flow: Oracle session → 127.0.0.1:5005 on the DB host → sshd → your
machine’s 5005 → the IDE’s JDI listener. Works through bastions too,
as long as the -R listener lands on a host the database server can
reach (tunnel to the DB host itself and loopback always works; on a
separate bastion, Oracle must be able to reach bastion:5005 and
you’d set PELL_DEBUG_CALLBACK_HOST=<bastion>).
The same two variables work for the standalone CLI:
pell debug-target script.pell --jdwp 127.0.0.1:5005.
CLI pieces (usable standalone)
pell deploy app/ --debug # markers + PLSQL_DEBUG=TRUE units
pell srcmap app/billing.pell # pell↔unit line map (JSON)
pell srcmap stub.pell --anon # same for an exec script's block
pell debug-target stub.pell --jdwp 192.168.1.10:5005 [--wait-for-go]
pell debug-source HR.EMPLOYEES --type PACKAGE_BODY
ClassPrepare must be filtered (the FORALL ORA-00600)
Debugging a unit with a FORALL over a record collection (forall x in
<list<Record>> { sql!{ insert ... :x.field ... } }) once threw an Oracle
internal error and dropped the connection:
ORA-00600: internal error code, arguments: [15419], [severe error
during PL/SQL execution], [2649], ...
ORA-06553: PLS-707: unsupported construct or internal error [2649]
DPY-4011: the database or network closed the connection
The cause was not the FORALL and not the compile mode (it
reproduces under both PLSQL_DEBUG=TRUE and the modern
PLSQL_OPTIMIZE_LEVEL=1). It was a blanket all-classes ClassPrepare
request. pell emits a record collection as a nested type
($Oracle.PackageBody.SCHEMA.PKG.T_<rec>_LIST plus a $element and
array form), and Oracle prepares those types lazily, mid-FORALL. With
a blanket ClassPrepare armed, delivering a prepare event for one of
those nested types during FORALL execution crashes the session.
The fix (in PellDebugProcess): arm ClassPrepare only for the exact
classes our breakpoints target — $Oracle.Block.* for the stub block
and *.<PKG> for each package body — never a blanket request. The
collection types then prepare silently (no matching request, no event),
and the FORALL is fully steppable: a breakpoint after it is reached
and hit normally. This is what SQL Developer does; it never rewrites your
code, and neither does pell.
pell debug-target still translates the raw ORA-00600 to a readable
hint in case a hand-written sql!{} block trips a different
debug-hostile construct.
Protocol notes (pinned by OracleJdwpProtocolTest)
Run the protocol test against any Oracle to re-verify the contract:
PELL_DEBUG_TEST_DB=pell_test/pell_test@localhost:11521/FREEPDB1 \
./gradlew test --tests '*OracleJdwpProtocolTest*'
- Oracle does not suspend after
CONNECT_TCP—--wait-for-goexists so short blocks can’t finish before breakpoints arm. - The target session needs
PLSQL_DEBUG=TRUEfor the anonymous block itself or stub frames report line-1(debug-targetsets it). - Strata is
[Java]; JDI line numbers equalALL_SOURCElines. - At a breakpoint the callee frame can already be on top; trust
thread.frames(), not the event location. STEP_OVERstops at the next line at same-or-shallower frame depth — in a loop the landing line can be numerically smaller.- Locals arrive as
$Oracle.Builtin.*object mirrors; values render viatoStringinvocation with a type-name fallback.