Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ tests/fixtures/c/*.dSYM
# Java build artifacts
*.class
tests/fixtures/java/multi-package/out/
src/dap/adapters/java/target/

# Generated at build time
src/dap/adapters/java/adapter-sources.tar.gz

# Demo recordings & generated assets
demo/**/recording.cast
demo/**/__hotpatch_tmp/
docs/java-hotpatch.gif
2 changes: 1 addition & 1 deletion build.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { $ } from "bun";

// Generate Java adapter sources tarball
await $`tar czf src/dap/adapters/java/adapter-sources.tar.gz -C src/dap/adapters/java com/debugthat/adapter`;
await $`tar czf src/dap/adapters/java/adapter-sources.tar.gz -C src/dap/adapters/java com/debugthat/adapter pom.xml`;

// Bundle
await $`bun build src/main.ts --outdir dist --target=bun`;
Expand Down
34 changes: 34 additions & 0 deletions demo/java-hotpatch/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.4</version>
</parent>
<groupId>com.example</groupId>
<artifactId>pricing-service</artifactId>
<version>0.0.1</version>

<properties>
<java.version>17</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
130 changes: 130 additions & 0 deletions demo/java-hotpatch/record.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
#!/bin/bash
# Demo: Java hotpatch on a Spring Boot app — fix a bug without restarting
#
# Prerequisites:
# 1. cd demo/java-hotpatch && mvn -q compile
# 2. Start Spring Boot in a separate terminal:
# mvn spring-boot:run -Dspring-boot.run.jvmArguments="-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005"
# 3. Record:
# asciinema rec recording.cast --cols 100 --rows 35 --command "bash record.sh"
# 4. Generate GIF:
# agg recording.cast ../../docs/java-hotpatch.gif --theme monokai --font-size 14

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SRC="src/main/java/com/example/PricingController.java"
SRC_FIXED="src/main/java/com/example/PricingController.java.fixed"
BUGGY_LINE='subtotal - discount + (discount * VAT_RATE)'
FIXED_LINE='(subtotal - discount) * (1 + VAT_RATE)'

type_cmd() {
local cmd="$1"
local delay="${2:-0.02}"
printf "\033[1;32m❯\033[0m "
for (( i=0; i<${#cmd}; i++ )); do
printf "%s" "${cmd:$i:1}"
sleep "$delay"
done
sleep 0.3
printf "\n"
}

run() {
local cmd="$1"
local pause_before="${2:-1}"
local pause_after="${3:-2}"
sleep "$pause_before"
type_cmd "$cmd"
eval "$cmd"
sleep "$pause_after"
}

comment() {
sleep "${2:-0.8}"
printf "\033[1;33m# %s\033[0m\n" "$1"
sleep "${3:-1}"
}

show_diff() {
local old="$1" new="$2"
diff -u "$old" "$new" | tail -n +3 | while IFS= read -r line; do
case "$line" in
@@*) ;;
-*) printf "\033[1;31m - %s\033[0m\n" "${line:1}" ;;
+*) printf "\033[1;32m + %s\033[0m\n" "${line:1}" ;;
*) printf "\033[90m %s\033[0m\n" "$line" ;;
esac
done
}

# ── Setup ──
cd "$SCRIPT_DIR"

# Ensure buggy source is active
sed -i '' "s|$FIXED_LINE|$BUGGY_LINE|" "$SRC" 2>/dev/null || true
sed -i '' 's|// VAT applied to net amount (subtotal - discount)|// BUG: VAT applied to discount instead of net amount|' "$SRC" 2>/dev/null || true

dbg stop 2>/dev/null
export FORCE_COLOR=1

# Verify Spring Boot is running
if ! curl -m 2 -s localhost:8080/price > /dev/null 2>&1; then
echo "Error: Spring Boot is not running on port 8080."
echo "Start it first: mvn spring-boot:run -Dspring-boot.run.jvmArguments=\"-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005\""
exit 1
fi

# ── Demo starts ──

comment "Spring Boot pricing API has a VAT bug. Let's hotpatch it live." 0.2 1.5

# 1. Show the bug
run "curl -s localhost:8080/price | python3 -m json.tool" 0.5 2
comment "Total is 133.97 — expected (149.97 - 20) * 1.20 = 155.96" 0.3 1.5

# 2. Attach debugger
run "dbg attach 5005 --runtime java" 0.5 1.5

# 3. Set breakpoint on the buggy line
run "dbg break $SRC:21" 0.3 1

# 4. Trigger a request (blocks at breakpoint)
comment "Trigger a request to hit the breakpoint:" 0.3 0.8
curl -s localhost:8080/price > /dev/null &
sleep 3

# 5. Show source with colors (paused at the bug)
run "dbg source --lines 20" 0.3 2.5

# 6. Eval to understand the bug
run 'dbg eval "subtotal - discount + (discount * 0.20)"' 0.5 1.5
comment "133.97 — VAT is on the discount, not the net amount" 0.3 1.5

# 7. Test the correct formula
run 'dbg eval "(subtotal - discount) * (1 + 0.20)"' 0.5 1.5
comment "155.96 — correct! Apply the fix:" 0.3 1.5

# 8. Show the diff and apply the fix
sleep 0.5
printf "\033[1;32m❯\033[0m "
type_cmd "# Applying fix..."
show_diff "$SRC" "$SRC_FIXED"
sleep 2
cp "$SRC_FIXED" "$SRC"

# 9. Hotpatch the running JVM
run "dbg hotpatch $SRC" 0.5 2.5

# 10. Remove breakpoint and continue
run "dbg break-rm BP#1" 0.3 0.3
run "dbg continue" 0.3 1.5

# 11. Verify — curl returns the fixed value
comment "Service is still running. Verify:" 0.5 0.8
run "curl -s localhost:8080/price | python3 -m json.tool" 0.5 3

comment "155.96 — fixed without restarting the JVM!" 0.3 2

# ── Cleanup ──
# Restore buggy source for next demo run
sed -i '' "s|$FIXED_LINE|$BUGGY_LINE|" "$SRC" 2>/dev/null || true
sed -i '' 's|// VAT applied to net amount (subtotal - discount)|// BUG: VAT applied to discount instead of net amount|' "$SRC" 2>/dev/null || true
29 changes: 29 additions & 0 deletions demo/java-hotpatch/show-diff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/bin/bash
# Show a colored inline diff between two files, with line numbers
# Usage: show-diff.sh <old-file> <new-file>

OLD="$1"
NEW="$2"

RED='\033[1;31m'
GREEN='\033[1;32m'
GRAY='\033[90m'
RESET='\033[0m'

# Get unified diff, skip header lines, show context
diff -u "$OLD" "$NEW" | tail -n +3 | while IFS= read -r line; do
case "$line" in
@@*)
# Extract line number from hunk header
;;
-*)
printf "${RED} - %s${RESET}\n" "${line:1}"
;;
+*)
printf "${GREEN} + %s${RESET}\n" "${line:1}"
;;
*)
printf "${GRAY} %s${RESET}\n" "$line"
;;
esac
done
11 changes: 11 additions & 0 deletions demo/java-hotpatch/src/main/java/com/example/PricingApp.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.example;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class PricingApp {
public static void main(String[] args) {
SpringApplication.run(PricingApp.class, args);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.example;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;

@RestController
public class PricingController {

private static final double VAT_RATE = 0.20;

@GetMapping("/price")
public Map<String, Object> getPrice(
@RequestParam(defaultValue = "49.99") double price,
@RequestParam(defaultValue = "3") int qty,
@RequestParam(defaultValue = "20") double discount) {

double subtotal = price * qty;
// BUG: VAT applied to discount instead of net amount
double total = subtotal - discount + (discount * VAT_RATE);
total = Math.round(total * 100.0) / 100.0;

return Map.of(
"subtotal", subtotal,
"discount", discount,
"vat_rate", VAT_RATE,
"total", total
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.example;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;

@RestController
public class PricingController {

private static final double VAT_RATE = 0.20;

@GetMapping("/price")
public Map<String, Object> getPrice(
@RequestParam(defaultValue = "49.99") double price,
@RequestParam(defaultValue = "3") int qty,
@RequestParam(defaultValue = "20") double discount) {

double subtotal = price * qty;
// VAT applied to net amount (subtotal - discount)
double total = (subtotal - discount) * (1 + VAT_RATE);
total = Math.round(total * 100.0) / 100.0;

return Map.of(
"subtotal", subtotal,
"discount", discount,
"vat_rate", VAT_RATE,
"total", total
);
}
}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"lint": "biome check .",
"format": "biome check --write .",
"typecheck": "tsc --noEmit -p tsconfig.check.json",
"build:java": "bun run src/main.ts install java",
"update-jsc-protocol": "curl -sL https://raw.githubusercontent.com/oven-sh/bun/main/packages/bun-inspector-protocol/src/protocol/jsc/index.d.ts -o src/cdp/jsc-protocol.d.ts"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion src/cdp/adapters/bun-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export class BunAdapter implements CdpDialect {
const entryScript = this.resolveEntryScript(session);
const tempBpId = await this.setEntryBreakpoint(entryScript);
try {
const waiter = session.createPauseWaiter(5_000);
const waiter = session.waitUntilStopped();
await this.jsc.send("Inspector.initialized");
await waiter;
} finally {
Expand Down
2 changes: 1 addition & 1 deletion src/cdp/adapters/node-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export class NodeAdapter implements CdpDialect {
skips < MAX_INTERNAL_PAUSE_SKIPS
) {
skips++;
const waiter = session.createPauseWaiter(5_000);
const waiter = session.waitUntilStopped();
await session.cdp.send("Debugger.resume");
await waiter;
}
Expand Down
9 changes: 8 additions & 1 deletion src/cdp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ interface PendingRequest {
timer: ReturnType<typeof setTimeout>;
}

export class TimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = "TimeoutError";
}
}

export class CdpClient {
private ws: WebSocket;
private nextId = 1;
Expand Down Expand Up @@ -150,7 +157,7 @@ export class CdpClient {

const timer = setTimeout(() => {
cleanup();
reject(new Error(`waitFor timed out: ${event} (after ${timeoutMs}ms)`));
reject(new TimeoutError(`waitFor timed out: ${event} (after ${timeoutMs}ms)`));
}, timeoutMs);

const cleanup = () => {
Expand Down
Loading
Loading