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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
- Added hang detection: configurable timeout (default 60 s) kills the process and shows an error if no PTY output is received after launch; set to 0 to disable
- Fixed authentication for profiles with the "Claude API" connection type: the API key is now correctly recognised by Claude Code on session start
- Added capability of changing the profile storage directory and sharing a single directory with several profiles
- Fixed drag-and-drop into text fields: files are now inserted at the text caret position instead of where the mouse was dropped
Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>io.github.nbclaudecodegui</groupId>
<artifactId>netbeans-claude-code-gui</artifactId>
<version>0.21.27-SNAPSHOT</version>
<version>0.21.28-SNAPSHOT</version>
<packaging>nbm</packaging>
<name>Netbeans Claude Code GUI</name>
<description>NetBeans plugin that provides a GUI for Claude Code CLI — run claude code sessions directly inside the IDE.</description>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ public final class ClaudeSessionController {
new javax.swing.Timer(400, e -> flushPendingPrompt());
{ promptFlushTimer.setRepeats(false); }

/** Timestamp (ms) when the PTY process was launched; used for hang detection. */
private volatile long processStartedAt = 0;

/** {@code true} once the first byte/line from the PTY has been received. */
private volatile boolean firstOutputReceived = false;

/**
* Called when a hang is detected: receives (command, errorMessage).
* Invoked on the EDT after the process is stopped.
*/
private java.util.function.BiConsumer<String, String> hangCallback;

/** {@code true} while a shift-tab thread is actively switching the CC edit mode. */
private volatile boolean modeSwitchInProgress = false;

Expand Down Expand Up @@ -116,6 +128,12 @@ public final class ClaudeSessionController {
private final java.util.Deque<String> recentPtyLines = new java.util.ArrayDeque<>();
private static final int PTY_LINE_BUFFER_SIZE = 40;

/** Package-private for tests: sets hang-detection state as if the process just started. */
void simulateProcessStart(long startedAt, boolean outputReceived) {
processStartedAt = startedAt;
firstOutputReceived = outputReceived;
}

/** Package-private for tests: injects a stripped PTY line into the rolling buffer. */
void simulatePtyLine(String line) {
synchronized (recentPtyLines) {
Expand Down Expand Up @@ -190,11 +208,15 @@ public void startProcess(java.io.File dir, String profileName, String extraCliAr
mode != null ? mode : SessionMode.NEW,
resumeSessionId);

processStartedAt = System.currentTimeMillis();
firstOutputReceived = false;

connector = new PtyTtyConnector(process);
connector.setSessionTag(tag);
screenContentDetector.setSessionTag(tag);

connector.setLineListener(line -> {
firstOutputReceived = true;
LOG.fine(tag + "[PTY line] " + line);
synchronized (recentPtyLines) {
recentPtyLines.addLast(line);
Expand Down Expand Up @@ -271,6 +293,8 @@ public void stopProcess() {
customModelIds = List.of();
standardModelCount = 0;
synchronized (recentPtyLines) { recentPtyLines.clear(); }
processStartedAt = 0;
firstOutputReceived = false;

model.setModelList(List.of(), -1);
model.clearChoiceMenu();
Expand Down Expand Up @@ -908,6 +932,18 @@ void pollScreenState() {
// UNKNOWN → no transition
}

// Hang detection: if no PTY output received within the configured timeout, kill the process
if (!firstOutputReceived && processStartedAt > 0) {
int timeoutSec = ClaudeCodePreferences.getHangTimeoutSeconds();
if (timeoutSec > 0) {
long elapsed = System.currentTimeMillis() - processStartedAt;
if (elapsed > timeoutSec * 1000L) {
handleHang(timeoutSec);
return;
}
}
}

// Continuously sync CC screen mode → model (skip during switches and discovery)
if (modelComboPopulated && !modeSwitchInProgress && !modelDiscoveryInProgress) {
Optional<String> detected = screenContentDetector.detectEditMode(lines);
Expand All @@ -926,6 +962,34 @@ void pollScreenState() {
}
}

/**
* Registers a callback invoked on the EDT when a hang is detected and the
* process has been stopped.
*
* @param callback receives (command, errorMessage); may be {@code null} to clear
*/
public void setHangCallback(java.util.function.BiConsumer<String, String> callback) {
this.hangCallback = callback;
}

/**
* Called from {@link #pollScreenState()} when the hang watchdog fires.
* Stops the process and notifies the registered callback on the EDT.
*
* @param timeoutSec the configured timeout that elapsed (for the error message)
*/
private void handleHang(int timeoutSec) {
LOG.warning("Hang detected: no PTY output received within " + timeoutSec + " s — killing process");
String command = claudeProcess != null ? claudeProcess.getLastCommand() : "";
stopProcess();
java.util.function.BiConsumer<String, String> cb = hangCallback;
if (cb != null) {
String msg = "No output received within " + timeoutSec
+ " seconds. Process was killed.";
cb.accept(command, msg);
}
}

private void detectAndApplyInitialEditMode(List<String> lines) {
screenContentDetector.detectEditMode(lines).ifPresent(m -> model.setEditMode(m));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ public final class ClaudeCodeOptionsPanel extends JPanel {
private javax.swing.JCheckBox startNewSessionCheck;
/** Spinner for the maximum number of sessions shown in the session list. */
private JSpinner sessionListLimitSpinner;
/** Spinner for the hang-detection timeout in seconds (0 = disabled). */
private JSpinner hangTimeoutSpinner;

/** send-key radio buttons: value → button */
private final Map<String, JRadioButton> sendRadios = new LinkedHashMap<>();
Expand Down Expand Up @@ -227,6 +229,14 @@ private JPanel buildGeneralPanel() {
form.add(sessionListLimitSpinner, gbc(1, row, false));
row++;

// --- hang timeout ---
form.add(new JLabel("Hang timeout (seconds, 0=disabled):"), gbc(0, row, false));
hangTimeoutSpinner = new JSpinner(new SpinnerNumberModel(
ClaudeCodePreferences.DEFAULT_HANG_TIMEOUT_SECONDS, 0, 3600, 5));
hangTimeoutSpinner.setToolTipText("Kill the process if no PTY output is received within this many seconds after launch (0 = disabled)");
form.add(hangTimeoutSpinner, gbc(1, row, false));
row++;

// spacer
GridBagConstraints spacer = new GridBagConstraints();
spacer.gridx = 0; spacer.gridy = row;
Expand Down Expand Up @@ -304,6 +314,7 @@ void load() {
ClaudeCodePreferences.getContextMenuSessionMode()
== io.github.nbclaudecodegui.model.SessionMode.NEW);
sessionListLimitSpinner.setValue(ClaudeCodePreferences.getSessionListLimit());
hangTimeoutSpinner.setValue(ClaudeCodePreferences.getHangTimeoutSeconds());

String sendVal = ClaudeCodePreferences.getSendKey();
String newlineVal = ClaudeCodePreferences.getNewlineKey();
Expand Down Expand Up @@ -336,6 +347,7 @@ void store() {
? io.github.nbclaudecodegui.model.SessionMode.NEW
: io.github.nbclaudecodegui.model.SessionMode.CONTINUE_LAST);
ClaudeCodePreferences.setSessionListLimit((Integer) sessionListLimitSpinner.getValue());
ClaudeCodePreferences.setHangTimeoutSeconds((Integer) hangTimeoutSpinner.getValue());
ClaudeCodePreferences.setSendKey(selectedValue(sendRadios));
ClaudeCodePreferences.setNewlineKey(selectedValue(newlineRadios));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,35 @@ public static void setSessionListLimit(int v) {
.putInt(KEY_SESSION_LIST_LIMIT, Math.max(1, Math.min(500, v)));
}

// -------------------------------------------------------------------------
// hangTimeoutSeconds
// -------------------------------------------------------------------------

/** Preference key: seconds of PTY silence before a hang is declared (0 = disabled). */
public static final String KEY_HANG_TIMEOUT_SECONDS = "hangTimeoutSeconds";
/** Default: 60 seconds. */
public static final int DEFAULT_HANG_TIMEOUT_SECONDS = 60;

/**
* Returns the configured hang-detection timeout in seconds.
*
* @return timeout in seconds, or {@code 0} to disable hang detection
*/
public static int getHangTimeoutSeconds() {
return NbPreferences.forModule(ClaudeCodePreferences.class)
.getInt(KEY_HANG_TIMEOUT_SECONDS, DEFAULT_HANG_TIMEOUT_SECONDS);
}

/**
* Persists the hang-detection timeout.
*
* @param v timeout in seconds; clamped to [0, 3600]; 0 disables detection
*/
public static void setHangTimeoutSeconds(int v) {
NbPreferences.forModule(ClaudeCodePreferences.class)
.putInt(KEY_HANG_TIMEOUT_SECONDS, Math.max(0, Math.min(3600, v)));
}

private static String validated(String value, String fallback) {
return ENTER.equals(value) || SHIFT_ENTER.equals(value)
|| CTRL_ENTER.equals(value) || ALT_ENTER.equals(value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,7 @@ public ClaudeSessionTab(File dir) {
setIcon(ImageUtilities.loadImage(ICON, true));
updateDisplayName(dir);
model.addListener(this);
controller.setHangCallback(this::showStartError);
}

// -------------------------------------------------------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,71 @@ void detectEditMode_emptyResultMeansDefaultModeOnIdleScreen() {
+ "sendShiftTabsUntilMode uses empty+target=default as success condition");
}

// -------------------------------------------------------------------------
// Hang detection
// -------------------------------------------------------------------------

@Test
void hangDetectedWhenNoOutputReceivedWithinTimeout() {
// Simulate process started 70 s ago with no output; timeout = 60 s
controller.simulateProcessStart(System.currentTimeMillis() - 70_000L, false);

AtomicReference<String> callbackCommand = new AtomicReference<>();
AtomicReference<String> callbackError = new AtomicReference<>();
controller.setHangCallback((cmd, err) -> {
callbackCommand.set(cmd);
callbackError.set(err);
});

// pollScreenState() is called on the EDT via Timer — invoke directly in test
controller.pollScreenState();

assertNotNull(callbackCommand.get(), "Hang callback should have been invoked");
assertTrue(callbackError.get().contains("60"), "Error message should include the timeout seconds");
}

@Test
void hangNotDetectedWhenOutputAlreadyReceived() {
// Simulate process started 70 s ago but output was received
controller.simulateProcessStart(System.currentTimeMillis() - 70_000L, true);

AtomicReference<String> callbackCommand = new AtomicReference<>();
controller.setHangCallback((cmd, err) -> callbackCommand.set(cmd));

controller.pollScreenState();

assertNull(callbackCommand.get(), "Hang callback should NOT fire when output was received");
}

@Test
void hangNotDetectedWhenTimeoutIsZero() {
// Simulate process started 70 s ago with no output; timeout = 0 (disabled)
// We need to temporarily override the preference — but preferences require NB platform.
// Instead, verify via processStartedAt=0 (stopProcess sets it to 0, meaning no active process).
controller.simulateProcessStart(0L, false);

AtomicReference<String> callbackCommand = new AtomicReference<>();
controller.setHangCallback((cmd, err) -> callbackCommand.set(cmd));

controller.pollScreenState();

assertNull(callbackCommand.get(), "Hang callback should NOT fire when processStartedAt=0");
}

@Test
void hangNotDetectedWhenWithinTimeout() {
// Simulate process started 10 s ago with no output; default timeout = 60 s
controller.simulateProcessStart(System.currentTimeMillis() - 10_000L, false);

AtomicReference<String> callbackCommand = new AtomicReference<>();
controller.setHangCallback((cmd, err) -> callbackCommand.set(cmd));

// Since NbPreferences returns default (60 s) in tests, 10 s < 60 s → no hang
controller.pollScreenState();

assertNull(callbackCommand.get(), "Hang callback should NOT fire when elapsed < timeout");
}

// -------------------------------------------------------------------------
// No-op listener base class
// -------------------------------------------------------------------------
Expand Down
Loading