Repository navigation
Conversation
Jabe03
left a comment
There was a problem hiding this comment.
Review of the safe mode run lifecycle. I'll address each comment with its own commit and reply here.
Not raised here: a cancel that arrives before Docker creates the container (pending issue 3 in the safe mode notes), since how to handle it hasn't been decided yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When Kind 2's output ends, the docker client is usually still waiting for the container's exit status, so checking isAlive() right away made almost every finished run call docker kill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replacing the semaphore left callers that were already waiting on the old one, so a lower limit was not enforced and a higher one did not wake them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
acquireSafeModeRun now returns a closeable slot (a no-op one outside safe mode) and null only when cancelled, so callers can't forget the release. It also checks the monitor once more after acquiring, so a run cancelled just as a slot frees up does not start a container. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The interpreter has no monitor, so a call made while the run limit was reached blocked the caller's thread until a long check finished. It now throws a Kind2Exception instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
checkAvailable, checks and the interpreter now share getKind2Command, so checkAvailable runs the same command that execute does. The generated command lines are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
stderr was never read, so docker errors in safe mode were lost, and enough stderr output blocked Kind 2 and hung the run. It is now read on its own thread, and if no trace comes back, interpret throws a Kind2Exception with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It was encoded with the platform's default charset while the output is read as UTF-8, so non-ASCII programs broke on Windows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
daniel-larraz
left a comment
There was a problem hiding this comment.
Review of the safe mode run management and interpreter error reporting. I checked each finding against the code; gson behavior was tested against 2.8.6.
The two worth fixing first are on the new interpreter error-reporting path: harmless stderr now replaces the empty result (L407), and parse failures escape as raw gson exceptions (L398). The rest are lower severity: cleanup/race edge cases, and a few smaller consistency and robustness points.
🤖 Generated with Claude Code
| errorReader.join(DOCKER_EXIT_GRACE_MILLIS); | ||
| String errorOutput = errors.toString().trim(); | ||
| if (!errorOutput.isEmpty()) { | ||
| throw new Kind2Exception("Error running the Kind 2 interpreter: " + errorOutput); |
There was a problem hiding this comment.
Harmless stderr output now replaces the empty result. On main the interpreter never read stderr, so a run without a trace returned "". Now any stderr text throws when there is no trace. For example, on hosts without swap accounting docker prints WARNING: Your kernel does not support swap limit capabilities ... on every run that sets --memory/--memory-swap. A Kind 2 error reported as JSON on stdout (no trace element) then turns into Error running the Kind 2 interpreter: WARNING: ..., which hides the real error.
Suggestion: report stderr only when stdout produced no output at all, or attach it to the message instead of changing the empty-trace behavior.
| trace = jele.getAsJsonObject().get("trace").toString(); | ||
| } | ||
| } | ||
| } catch (JsonIOException e) { |
There was a problem hiding this comment.
Only JsonIOException is caught here, and it is rethrown raw. I checked with gson 2.8.6 (JsonStreamParser is lenient):
- empty stdout →
JsonIOException(End of input). When stderr is also empty,throw outputErrorlets it escape unwrapped, not as aKind2Exception. - non-JSON stdout such as
Error: docker failed→JsonSyntaxException. That isn't caught, so it goes pastcatch (IOException)and the new stderr report is skipped.
Catching JsonParseException (the parent of both) and wrapping it in Kind2Exception would fix both cases.
| */ | ||
| public String checkAvailable() throws Exception { | ||
| ProcessBuilder builder = new ProcessBuilder(KIND2, "--version"); | ||
| List<String> command = getKind2Command(null, new ArrayList<>()); |
There was a problem hiding this comment.
In safe mode, checkAvailable now starts a full docker run outside the new run management. It uses the per-run CPU/memory limits but takes no run slot, gives the container no --name, and sets no timeout. If docker hangs (image pull, stuck daemon), readAll + waitFor() block forever, and there is no name to docker kill.
| if (process != null) { | ||
| // Destroying the docker client does not reliably stop the container (for example on | ||
| // Windows), so kill the container itself if the run did not finish on its own. | ||
| if (monitor.isCanceled() || !exitsOnItsOwn(process, containerName)) { |
There was a problem hiding this comment.
The kill can race with container creation. If the run is cancelled while docker run is still pulling or creating, docker kill <name> fails with "No such container" (only logged through debug). Then process.destroy() kills the client.
- If the client dies before it sends start, the container is left in the Created state. It doesn't run, but
--rmnever removes it. - If the client sends start in the window between the failed kill and
destroy(), the container runs with no owner after the slot is released. On Linux the SIGTERM fromdestroy()is normally forwarded to the container (sig-proxy), so this mostly matters on Windows, where the client is terminated without a signal.
Retrying the kill, or running docker rm -f <name> after the client exits, would close the gap.
| exceptionThrown = true; | ||
| throw t; | ||
| } finally { | ||
| RunSlot slot = acquireSafeModeRun(monitor, 0); |
There was a problem hiding this comment.
execute(String) / execute(Program) can wait forever for a slot. Their monitors never cancel, and the timeout here is 0, so once all slots are held (e.g. by hung runs) these calls block in the tryAcquire loop with no error. That matches the Javadoc ("0 to wait as long as it takes"), but it's inconsistent with interpret, which gives up after 60 s.
| return process.waitFor(DOCKER_EXIT_GRACE_MILLIS, TimeUnit.MILLISECONDS); | ||
| } catch (InterruptedException e) { | ||
| Thread.currentThread().interrupt(); | ||
| return !process.isAlive(); |
There was a problem hiding this comment.
An interrupted thread skips the kill wait. After an interrupt this re-sets the flag and returns !isAlive() (usually false), so killContainer runs with the flag set and kill.waitFor throws at once. The docker kill child still runs and kills the container, so this is low severity, but:
- the slot is released before the container is confirmed stopped, so a new run can briefly go over the cap;
- a
docker killthat hangs is never destroyed.
Clearing the interrupt flag around the cleanup and restoring it afterwards would avoid this.
| private void callKind2(String program, Result result, IProgressMonitor monitor, ResultListener listener) | ||
| throws IOException, InterruptedException { | ||
| ProcessBuilder builder = getKind2ProcessBuilder(); | ||
| String containerName = newContainerName(); |
There was a problem hiding this comment.
safeMode is read several times per run (newContainerName, getKind2Command, acquireSafeModeRun). A concurrent setSafeMode(true) can produce a docker run with no --name (which killContainer/exitsOnItsOwn then treat as native), or a native run that holds a slot. Changing settings during a run isn't really supported, since every setting is a plain field, so this is minor. Reading safeMode once into a local and passing it down is a cheap fix.
| monitor.done(); | ||
| return; | ||
| } | ||
| try (RunSlot heldSlot = slot) { |
There was a problem hiding this comment.
Nit: two styles for holding a slot. Here the slot is null-checked and then rebound as heldSlot, while runInterpreter acquires it in the try header. The nullable return (null only on cancel) means every caller has to remember the null check before try-with-resources. Throwing on cancel, or a RunSlot with a cancelled flag, would allow a single pattern.
| final StringBuffer errors = new StringBuffer(); | ||
| Thread errorReader = new Thread(() -> { | ||
| try { | ||
| errors.append(ApiUtil.readAll(errorStream)); |
There was a problem hiding this comment.
Minor: ApiUtil.readAll turns each byte into a char ((char) i), so the stderr text in the new error message is decoded as Latin-1. A docker error that quotes a non-ASCII host path (e.g. /home/josé/...) comes out garbled, unlike the UTF-8 handling this PR adds for stdin and stdout. Reading through an InputStreamReader with UTF-8 would fix it.
Stop safe mode containers on cancel and cap concurrent runs
Follow-up to #30. Needed by kind2-mc/kind2-language-server#22, which cancels out-of-date parses: without this, cancelling a safe mode run stops the
dockerclient but can leave the container running.Changes
docker runcommand:getDockerRunCommand()builds the resource limits and the Kind 2/solver mounts once, for checks, the interpreter andcheckAvailable().--init: the container runs an init process as PID 1, so signals reach Kind 2 and orphaned solver processes are reaped.--name(kind2-api-<uuid>). If the run is cancelled or the process is still alive when it ends, the API runsdocker killon that container before destroying the client process. Destroying the client alone doesn't reliably stop the container (for example on Windows).Kind2Apiinstance limits how many safe mode runs happen at once (default 2, set with the new staticsetSafeModeMaxRuns(int)). Waiting runs still respond to their monitor's cancellation.checkAvailable()in safe mode now runs Kind 2 inside the container, and waits for the process instead of readingexitValue()before it has exited.--no_includeis only passed when safe mode is on (it used to be passed as--no_include falsewhen safe mode was explicitly off).interpretoverloads now share onerunInterpretermethod.docker killtimeout now applies; finished runs get a short grace period instead of being killed;setSafeModeMaxRunsresizes the limit in place, so it also applies to waiting runs; slots are held with try-with-resources; the interpreter stops waiting for a slot after 60 s; one helper builds the Kind 2 command prefix; the interpreter reads stderr and reports it when no trace comes back; programs are sent as UTF-8.Outside safe mode the command line is unchanged.
Testing
./gradlew clean buildpasses.kind2(sleeps, then prints a trace): finished checks and interpreter runs call nodocker kill, a cancelled check is killed with no container left, raising and lowering the limit while runs wait takes effect right away, and the interpreter gives up after 60 s when no slot is free.Kind2Api(setSafeMode(true)) left no running containers and no leftoverz3processes in 4 of 4 tries.🤖 Generated with Claude Code