diff --git a/README.md b/README.md index 9132ca7..2babc11 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,20 @@ The tool receives only the area; **results come back on the right place of the s The downsample of an export is 1 for now and is kept next to the offset (`downsample`, always 1.0), so the later resolution step changes one place. +## Tools compatibility (docs/PROTOCOL.md, "Compatibility rules for hosts") +Every app is checked when the registry is read (start, **Rescan apps**), against the cached schema the app's labconstrictor-tools wrote: `protocol` must be 1, and this host needs **labconstrictor-tools 0.3.0a1 or newer** with the features `reserved_inputs_v1` (the `_job_dir` and `_calibration` inputs) and `retention_prune` (the `prune` command; the run-record helpers arrived in 0.3.0a1). The rules are those of `labconstrictor_tools.compat.check_schema`, written in Groovy (`LcCompat`) and tested against every vector of `conformance.json` (`compat`): +* an app that lacks something is **not offered** and is listed under "skipped" with one sentence, e.g. `runs: this app's labconstrictor-tools 0.2.0rc1 is older than the 0.3.0a1 this host needs: update it`, `... lacks retention_prune: update it`, `... is too old (it lists no features, this host needs reserved_inputs_v1, retention_prune): update it`; +* an app whose version text is not understood is offered with a warning in the status line; +* a run is checked again when Run is pressed (an app registered after the window opened is checked at the next Rescan; the run check is the last line). + +## Dynamic choices and the worker (SPEC_WRONG_SCIENCE section 5) +A `ChoicesFrom` parameter is a dropdown filled by another tool of the app. Before this, one thread per parameter shared one unlocked worker field, and a late answer of another form could fill the dropdown of the form shown now. Now: +* **Identity of a lookup.** Each question is `(form generation, app, tool, parameter, the values it depends on, request id)`. The generation is process-wide and never reset (it grows whenever a form is built or destroyed); the request id is per window and never reset. An answer is applied **only if all of these still hold** when it arrives (the dependencies are recomputed then, not remembered); otherwise it is dropped, with its error and its tooltip. A tool or app switched while a lookup is out can therefore never put its choices (or its error) on the new form. +* **A dependency change invalidates at once.** The moment a value the dropdown depends on changes, its choices are marked pending (the dropdown is replaced by the text field with the tooltip "waiting for the choices...") and every older question of it is stale; only the new question waits for the 400 ms pause in the typing. +* **Bounded work.** One lookup in flight per window (one thread, no thread per lookup); waiting questions are coalesced (one slot per parameter, a newer one replaces an older one that has not started); at most 8 wait, the oldest is dropped and logged; a lookup that takes longer than 60 s is ended with its worker (shortened for tests with `-Dlc.qupath.lookup_timeout_s`; the status says `... did not answer within N s`). These three numbers are `status: default` (not yet confirmed) in `conformance.json`. +* **One owner for the workers** (`LcWorkers`, one lock). A run or a lookup leases a worker when it is submitted and uses only that one until it ends. A lookup never uses the worker a run holds and never starts while a run is active (it asks again after 1.5 s); a run waits for a lookup that holds its worker. An app switch during a run never replaces the run's worker (the run goes on with it; the new app's worker is made for the next job, and the old one is closed when its last job ends). Cancel goes to the worker of the run. A worker that crashed or hit its time limit is discarded, never reused. +* **Closing the window** drops the waiting lookups, ends the one in flight, stops the lookup thread and closes every worker (a run in progress is a cancellation, see Results); nothing is started afterwards, and a worker that was being made at that moment is closed too. + ## Number limits (SPEC_WRONG_SCIENCE section 6) * A whole-number box accepts ±(2^53 - 1) = ±9007199254740991 (`conformance.json` `limits.form_integer_max`) and a decimal box ±1e15 (`limits.unbounded_float`) when the tool declares no `Min` / `Max`; a declared bound is used as declared (a whole-number bound beyond 2^53 - 1 is cut to it). * **The text in the box is the value.** A whole number is read as an exact integer (no double ever holds it), a decimal number is compared with the limits as its exact decimal text (so `1.0000000000000002e15` and even `1.00000000000000001e15` are beyond 1e15), and the request carries exactly what was typed (`9007199254740991` stays `9007199254740991`). Surrounding spaces are not part of the number; `+5` and `007` are 5 and 7. @@ -123,6 +137,10 @@ The `gui_test_parity_*` bodies need the registered `interactions` app (its Pytho `BODY=gui_test_parity_pixel_body.groovy LC_HOME= tests/run_gui_test.sh` reads the pixel size of files (ImageJ unit, centimetre TIFF unit), keeps a typed value, says non-square pixels, reports an unreadable file (15 checks). +`BODY=gui_test_conformance_body.groovy tests/run_gui_test.sh` reads `conformance.json` (`LC_CONFORMANCE`, else `python -m labconstrictor_tools.conformance --path`): it validates the file with Tools' own `--validate`, compares **every** limit, message template and host-internal code of the file with this script's constants (a key this script does not know fails: a new rule is a failing test until the host follows it), runs every `compat` vector through `LcCompat`, runs the done-status rule through the real Tools helper for every way a run can end, and keeps a ledger: every fixture id of the file is mapped to the body that runs it or to the reason it does not apply to QuPath (an id that is in the file and not in the ledger fails; the ones that cannot run here are printed as `UNSUPPORTED`). + +`BODY=gui_test_async_body.groovy LC_HOME= tests/run_gui_test.sh` runs ASY-01..08 of `conformance.json` through the real dialog with the gated source tools of the `runs` test app (a gate file is the barrier; the dependency of the dropdown is the gate path): a delayed answer that comes back after a tool switch, a dependency change and a failure, 50 changes in a second, 12 questions at once, two dropdowns, a run followed by an app switch and a lookup, a lookup past its time limit, closing the window with a lookup in flight (also while a worker is being made), and three threads leasing, releasing, reaping and closing fake workers at fixed and random interleavings. + `BODY=gui_test_numbers_body.groovy LC_HOME= tests/run_gui_test.sh` runs NUM-01..05 of `conformance.json` against the `Numbers` tool of the `runs` test app (it reports the text and the Python type of what its request carried): the limits and the sentences are read from the conformance file, a typed number beyond a limit is refused with no run and no request and its text stays in the box, a declared Max, decimals that a double cannot tell apart, the text rules, an optional number, Enter and focus lost, and the arrows. `BODY=gui_test_parity_form_body.groovy LC_HOME= tests/run_gui_test.sh` registers a synthetic app and checks the form order, ClearAfterRun for every type, EnabledWhen for every driver, number limits (whole numbers up to 2^53 - 1, held; a typed number beyond the limit refused), integer choices, required and non-finite inputs (55 checks). diff --git a/docs/user-guide.md b/docs/user-guide.md index 18446ad..5293640 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -35,6 +35,7 @@ Choose **Extensions > LabConstrictor tools...**, pick an app and a tool, and pre - Results and run records are kept in `~/.labconstrictor/results` and `~/.labconstrictor/runs`. The extension never deletes them; after each run it starts `labconstrictor-tools prune`, which keeps the newest 20 results and 50 run records and removes only folders LabConstrictor made and finished. Folders from older versions of the extension are not removed automatically. - The app's `labconstrictor-tools` must be 0.3.0a1 or newer; with an older one the run is refused and the status line says to update it. - If you open another image while a tool runs, the results are not drawn on the new image: the status line says where the files are. +- An app whose `labconstrictor-tools` is older than 0.3.0a1 is not offered; the status line (or the list of skipped apps after **Rescan apps**) says to update it. - A number you type that is outside the allowed range (or is not a number) is refused with a sentence like `'Threshold' must be <= 10`; it is never changed to fit, and the run does not start. - `labconstrictor-tools doctor` shows which apps are registered. diff --git a/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LabConstrictorTools.groovy b/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LabConstrictorTools.groovy index 14ce249..c2d36ec 100644 --- a/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LabConstrictorTools.groovy +++ b/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LabConstrictorTools.groovy @@ -94,6 +94,10 @@ class LcConst { static final int CLOSE_WAIT_SECONDS = 10 // a worker that ignores its closed input is killed after this static final int CHOICES_DEBOUNCE_MS = 400 // wait for typing to stop before asking for dropdown choices again static final int CHOICES_BUSY_RETRY_MS = 1500 // ask again this long after, when the worker was busy with a run + static final int LOOKUP_IN_FLIGHT_MAX = 1 // ChoicesFrom lookups running at once per window (conformance limits.lookup_in_flight_max) + static final int LOOKUP_PENDING_MAX = 8 // lookups waiting per window; the oldest is dropped and logged (limits.lookup_pending_max) + static final int LOOKUP_TIMEOUT_SECONDS = 60 // a lookup that takes longer is ended with its worker (limits.lookup_timeout_s) + static final String LOOKUP_TIMEOUT_PROPERTY = "lc.qupath.lookup_timeout_s" // system property that replaces LOOKUP_TIMEOUT_SECONDS (the tests use it to end a lookup quickly) static final int CHOICES_AFTER_RUN_MS = 200 // ask again this long after a run finished // text excerpts (characters) static final int LOG_LINE_CHARS = 200 // a worker line that is not a protocol message, as logged @@ -804,12 +808,15 @@ class LcRegistry { if (reason) return [problem: entry.name + ": ignored: " + reason, name: entry.name] def schema = LcJson.parse(new File(entry.schema_path as String)) def badSchema = schemaProblem(schema) - return badSchema ? [problem: file.name + ": " + badSchema] : [app: [entry: entry, schema: schema], name: entry.name] + if (badSchema) return [problem: file.name + ": " + badSchema] + Map compat = LcCompat.check(schema as Map, LcCompat.MIN_TOOLS_VERSION, LcCompat.REQUIRED_FEATURES) // what this host needs from the app's Tools (PROTOCOL.md, compatibility rules for hosts) + if (compat.status == LcCompat.REFUSE) return [problem: entry.name + ": " + compat.reason, name: entry.name] + return [app: [entry: entry, schema: schema], name: entry.name] + (compat.status == LcCompat.WARN ? [warning: entry.name + ": " + compat.reason] : [:]) } /** @return [apps: LinkedHashMap name -> [entry: Map, schema: Map], problems: List of "name: reason"] */ static Map load() { - def apps = new LinkedHashMap(), problems = [], skip = [] as Set + def apps = new LinkedHashMap(), problems = [], warnings = [], skip = [] as Set searchPath().eachWithIndex { File dir, int index -> if (!dir.isDirectory()) return def files = (dir.listFiles({ File f -> f.name.endsWith(".json") && !f.name.endsWith(".schema.json") } as FileFilter) ?: []).sort { it.name } @@ -821,19 +828,21 @@ class LcRegistry { if (found.problem) { problems << found.problem; skip << (found.name ?: name); continue } if (apps.containsKey(found.name) || skip.contains(found.name)) continue // the file name differs from the app name it claims: priority stays with the earlier folder apps[found.name] = found.app + if (found.warning) warnings << found.warning } catch (Exception e) { // broad on purpose: any broken registry entry (I/O, JSON) is one listed problem, never a failed start LcLog.warn("registry entry '" + name + "' skipped", e) problems << (file.name + ": " + e.message) } } } - return [apps: new LinkedHashMap(apps.sort { it.key }), problems: problems] // sorted by name, as Napari and the command line list them + return [apps: new LinkedHashMap(apps.sort { it.key }), problems: problems, warnings: warnings] // sorted by name, as Napari and the command line list them } } // ---------------------------------------------------------------------------------------------------- the worker /** One Python worker process of an app: JSON lines on stdin/stdout, one reader thread per stream, one result queue per running task. */ -class LcWorker { +class LcWorker implements LcWorkerLike { + static final java.util.concurrent.atomic.AtomicInteger STARTED = new java.util.concurrent.atomic.AtomicInteger(0) // worker processes this JVM has started (the tests count them) Process proc final Map> tasks = new java.util.concurrent.ConcurrentHashMap<>() final StringBuffer stderrTail = new StringBuffer() @@ -848,6 +857,7 @@ class LcWorker { def pb = new ProcessBuilder(command) LcPython.applyEnvironment(pb, entry) proc = pb.start() + STARTED.incrementAndGet() Thread.start("lc-stdout") { try { proc.inputStream.withReader("UTF-8") { reader -> @@ -882,6 +892,8 @@ class LcWorker { } } + boolean alive() { return proc.isAlive() } + /** The worker's last error output (trimmed to the end when it grows too long, see LcConst.STDERR_*). */ String stderrText() { stderrTail.toString() } @@ -906,10 +918,12 @@ class LcWorker { return hintForExit(code, tail) + (code != null ? " (exit code " + code + ")" : "") + (tail ? ". " + tail : "") } - /** Sends one EXECUTE and blocks until its terminal message; `onUpdate` gets (message, current, maximum). */ - Map run(String toolId, Map inputs, Closure onUpdate) { + /** Sends one EXECUTE and blocks until its terminal message; `onUpdate` gets (message, current, maximum). With `timeoutSeconds` above 0 a task that has not ended by then is ended + * together with the worker (a crash text says so): the worker is discarded, never reused. */ + Map run(String toolId, Map inputs, Closure onUpdate, int timeoutSeconds = 0) { def id = "lcq-" + (++counter) + "-" + System.nanoTime() cancelRequested = false + long deadline = timeoutSeconds > 0 ? System.nanoTime() + timeoutSeconds * 1_000_000_000L : Long.MAX_VALUE def queue = new LinkedBlockingQueue() tasks.put(id, queue) try { @@ -918,6 +932,7 @@ class LcWorker { def msg = queue.poll(LcConst.POLL_SECONDS, TimeUnit.SECONDS) if (msg == null) { if (!proc.isAlive()) return endOfRun([responseType: "CRASH", error: crashText()]) + if (System.nanoTime() > deadline) { kill(); return endOfRun([responseType: "CRASH", error: "the tool did not answer within " + timeoutSeconds + " s; its worker was ended"]) } continue } def type = msg.responseType @@ -1004,7 +1019,6 @@ class LcDialog { Map> channelBoxes = [:] // PickChannel: the channel chooser of an image parameter Map checks = [:] // the 'set' box of each optional parameter Map> choiceBoxes = [:] // ChoicesFrom: the dropdown beside the text field - Map choiceSeq = [:] // newest question per parameter: older answers are dropped PauseTransition choiceTimer = new PauseTransition(Duration.millis(LcConst.CHOICES_DEBOUNCE_MS)) int idleSeconds = LcConst.WORKER_IDLE_SECONDS // a kept worker that was not used for this long is closed (Napari keeps it for 10 minutes) PauseTransition idleTimer = new PauseTransition(Duration.seconds(LcConst.WORKER_IDLE_SECONDS)) @@ -1028,8 +1042,16 @@ class LcDialog { List imageDataListeners = [] // listeners on QuPath's image property (removed when the window closes) boolean programmatic = false // true while the host writes a pixel-size field (such a write is never a person's edit) Map fileFields = [:] // image parameter -> the field for a file path - LcWorker worker - String workerApp + final LcWorkers workers = new LcWorkers({ Map app -> new LcWorker(app.entry) } as Closure) // the one owner of the workers of this window (SPEC 5.5) + final LcLookups lookups = new LcLookups(this) // the ChoicesFrom lookups of this window (SPEC 5.1 - 5.4) + volatile LcLease runLease // the lease of the run in progress: Cancel goes to ITS worker + volatile boolean closedWindow = false + List warnings = [] // apps listed with a warning (their Tools is older than recommended or its version is not understood) + + /** The worker kept for reuse (the one of the last app used), or null. */ + LcWorker getWorker() { return workers.current as LcWorker } + + String getWorkerApp() { return workers.currentApp } volatile boolean running = false String lastReport = "" Map currentTool @@ -1038,6 +1060,7 @@ class LcDialog { this.qupath = qupath this.apps = registry.apps this.problems = registry.problems + this.warnings = registry.warnings ?: [] LcUnits.useApps(apps.values().collect { it.entry }) // the unit catalog comes from the labconstrictor-tools of a registered app } @@ -1051,13 +1074,23 @@ class LcDialog { root.padding = new Insets(10) if (apps.isEmpty()) { status.text = "No LabConstrictor app is registered on this machine." + (problems ? " Skipped: " + problems.join("; ") : "") + } else if (warnings) { + status.text = "Warning: " + warnings.join("; ") // an app is listed, but its Tools is older than recommended or its version is not understood } stage.scene = new Scene(root) - stage.onHidden = { if (running) worker?.cancelRequested = true; stopWorker(false); imageDataListeners.each { qupath?.imageDataProperty()?.removeListener(it as javafx.beans.value.ChangeListener) } } // also when closed from code + stage.onHidden = { if (running) (runLease?.worker as LcWorker)?.cancelRequested = true; closeWindow(); imageDataListeners.each { qupath?.imageDataProperty()?.removeListener(it as javafx.beans.value.ChangeListener) } } // also when closed from code stage.show() if (!apps.isEmpty()) appBox.value = apps.keySet().first() } + /** The window is closed (SPEC 5.6): the waiting lookups are dropped and the thread stops, every worker is closed (a run in progress ends with its worker), nothing is started afterwards. */ + void closeWindow() { + closedWindow = true + LcLookups.newGeneration() + lookups.close() + workers.closeAll(false) + } + /** Sizes, listeners and actions of the widgets that are fields of the dialog. */ void configureWidgets() { keepWorker.selected = true @@ -1125,10 +1158,11 @@ class LcDialog { def registry = LcRegistry.load() apps = registry.apps problems = registry.problems + warnings = registry.warnings ?: [] def current = appBox.value appBox.items.setAll(apps.keySet()) if (current in apps) appBox.value = current else if (!apps.isEmpty()) appBox.value = apps.keySet().first() - status.text = "Found " + apps.size() + " app(s)" + (problems ? "; skipped: " + problems.join("; ") : "") + status.text = "Found " + apps.size() + " app(s)" + (problems ? "; skipped: " + problems.join("; ") : "") + (warnings ? "; warning: " + warnings.join("; ") : "") } /** A new app was chosen: list its tools. */ @@ -1156,9 +1190,11 @@ class LcDialog { void onTool() { def app = apps[appBox.value] currentTool = app ? toolNames(app.schema.tools)[toolBox.value] : null + LcLookups.newGeneration() // the old form is destroyed (SPEC 5.1): its lookups are stale from now on + lookups.forgetWaiting() formBox.children.clear() getters.clear(); setters.clear(); controls.clear(); wrappers.clear(); imageBoxes.clear(); areaBoxes.clear(); imageOf.clear(); selectionBoxes.clear(); links.clear(); fileFields.clear() - checks.clear(); choiceBoxes.clear(); choiceSeq.clear(); channelBoxes.clear() + checks.clear(); choiceBoxes.clear(); channelBoxes.clear() description.text = currentTool?.description ?: "" if (currentTool == null) return def unsupported = unsupportedParameter(currentTool) @@ -1169,22 +1205,36 @@ class LcDialog { return } buildForm(currentTool.inputs) + LcLookups.newGeneration() // a new form is built hookChoices() scheduleChoices(0) } // ---- dynamic choices (ChoicesFrom), clear after run - /** Asks for the choices again (after a pause in the typing) whenever a parameter the choices depend on changes. */ + /** A value a ChoicesFrom parameter depends on changes: the choices it showed belong to the old value, so they are invalidated AT ONCE and marked pending (SPEC 5.3); only the new + * question waits for the pause in the typing. */ void hookChoices() { currentTool.inputs.findAll { it.choices_from }.each { p -> p.choices_from.depends.each { dep -> def control = controls[dep] - if (control instanceof TextField) control.textProperty().addListener({ o, a, b -> scheduleChoices(LcConst.CHOICES_DEBOUNCE_MS) } as javafx.beans.value.ChangeListener) - else if (control instanceof ComboBox) control.valueProperty().addListener({ o, a, b -> scheduleChoices(LcConst.CHOICES_DEBOUNCE_MS) } as javafx.beans.value.ChangeListener) + def changed = { o, a, b -> lookups.invalidate(p.name as String); markChoicesPending(p.name as String); scheduleChoices(LcConst.CHOICES_DEBOUNCE_MS) } as javafx.beans.value.ChangeListener + if (control instanceof TextField) control.textProperty().addListener(changed) + else if (control instanceof ComboBox) control.valueProperty().addListener(changed) } } } + /** The choices of `name` are not known now (a value they depend on changed): the old dropdown is not shown any more, the text field is, with a tooltip that says why. */ + void markChoicesPending(String name) { + ComboBox combo = choiceBoxes[name] + def field = controls[name] as TextField + if (combo == null || field == null) return + lookups.markPending(name) + combo.items.clear() + combo.visible = false; combo.managed = false; field.visible = true; field.managed = true + field.tooltip = new Tooltip("waiting for the choices...") + } + /** (Re)starts the timer that resolves the choices; a burst of changes becomes one question. */ void scheduleChoices(int delayMs) { if (choiceBoxes.isEmpty()) return @@ -1192,17 +1242,14 @@ class LcDialog { choiceTimer.playFromStart() } - /** Starts one worker-side question per ChoicesFrom parameter; each gets a number so a late answer for an older question is dropped. */ + /** Asks for the choices of every ChoicesFrom parameter (LcLookups: one thread, one question per parameter, stale answers dropped). A run in progress makes the questions wait. */ void resolveChoices() { - if (currentTool == null || choiceBoxes.isEmpty()) return + if (currentTool == null || choiceBoxes.isEmpty() || closedWindow) return if (running) { scheduleChoices(LcConst.CHOICES_BUSY_RETRY_MS); return } // the worker is busy with a run: ask again afterwards - def app = apps[appBox.value] - def appName = appBox.value currentTool.inputs.findAll { it.choices_from && choiceBoxes[it.name] }.each { p -> - def request = choiceRequest(p) - def number = (choiceSeq[p.name] = (choiceSeq[p.name] ?: 0) + 1) - if (request == null) { applyChoices(p.name, number, null, "waiting for a value of " + p.choices_from.depends.join(", ")); return } - Thread.start("lc-choices") { fetchChoices(p, request, number, app, appName) } + Map request = choiceRequest(p) + if (request == null) { lookups.invalidate(p.name as String); applyChoices(p.name as String, null, "waiting for a value of " + p.choices_from.depends.join(", ")); return } + lookups.ask(p, request) } } @@ -1219,36 +1266,12 @@ class LcDialog { return request } - /** Runs the source tool on the worker thread and hands its answer (or why there is none) to applyChoices on the FX thread. */ - void fetchChoices(Map p, Map request, int number, Map app, String appName) { - List options = null - String why = null - def tmp = Files.createTempDirectory("lcqchoices_").toFile() - try { - request["_job_dir"] = tmp.absolutePath - ensureWorker(app, appName) - def outcome = worker.run(p.choices_from.tool as String, request, null) - if (outcome.responseType == "COMPLETION") { - def found = (outcome.outputs?.results ?: []).find { it.type == "values" && it.values?.get(p.choices_from.field ?: "choices") instanceof List } - options = found?.values?.get(p.choices_from.field ?: "choices")?.collect { it.toString() } - if (options == null) why = "the source tool '" + p.choices_from.tool + "' did not return a list" - } else { - why = "the source tool '" + p.choices_from.tool + "' did not complete (" + outcome.responseType + ": " + outcome.error + ")" - } - } catch (Exception e) { - // broad on purpose (isolation boundary): whatever the source tool or its worker does, the form is never blocked; the text field remains - LcLog.warn("choices of '" + p.name + "' could not be loaded", e) - why = e.class.simpleName + ": " + e.message - } finally { LcLog.deleteTemp(tmp) } - def failure = why - Platform.runLater { applyChoices(p.name, number, options, failure) } - } - - /** `why` says why there are no options (the text field is used instead); the reason is logged once and, for a failure, shown in the status line. */ - void applyChoices(String name, int number, List options, String why = null) { - if (choiceSeq[name] != number) return // a late answer for another question or form + /** `why` says why there are no options (the text field is used instead); the reason is logged once and, for a failure, shown in the status line. Called on the FX thread by LcLookups + * only for an answer that is still the current one (SPEC 5.2). */ + void applyChoices(String name, List options, String why = null) { def combo = choiceBoxes[name], field = controls[name] if (combo == null) return + (field as TextField).tooltip = null if (!options) { combo.visible = false; combo.managed = false; field.visible = true; field.managed = true boolean first = LcLog.once("choices:" + currentTool?.id + ":" + name + ":" + (why == null ? "empty" : why.startsWith("waiting") ? "waiting" : "failed"), "'" + name + "' stays a text field" + (why ? ": " + why : " (the source tool listed nothing)")) @@ -2069,16 +2092,6 @@ class LcDialog { progress.progress = 0 } - /** The worker is started (or replaced) when there is none for this app, or it has died. */ - void ensureWorker(Map app, String appName) { - idleTimer.stop() - if (worker == null || workerApp != appName || worker.closed || !worker.proc.isAlive()) { - stopWorker(false) - worker = new LcWorker(app.entry) - workerApp = appName - } - } - /** Copies what the thread needs (the form may change meanwhile), runs on a thread, shows the outcome on the FX thread. */ void runInBackground(Map values) { LcRun run = runSnapshot @@ -2100,13 +2113,18 @@ class LcDialog { Map outcome Map inputs = null File recordDir = null + LcLease lease = null + LcWorker used = null def t0 = System.nanoTime() def tmp = Files.createTempDirectory("lcqupath_").toFile() try { inputs = prepareInputs(values, toolInputs, tmp, appName, toolId) recordDir = LcRuns.begin(appName, toolId) // the record folder exists from the start of the run, owner marker first (SPEC 3.2) - ensureWorker(app, appName) - outcome = worker.run(toolId, inputs) { message, current, maximum -> + idleTimer.stop() + lease = workers.lease(appName, app, LcWorkers.RUN) // this run uses this worker to its end, whatever the form does meanwhile (SPEC 5.5) + runLease = lease + used = lease.worker as LcWorker + outcome = used.run(toolId, inputs) { message, current, maximum -> Platform.runLater { status.text = message progress.progress = (current != null && maximum) ? (current as double) / (maximum as double) : ProgressBar.INDETERMINATE_PROGRESS @@ -2120,12 +2138,19 @@ class LcDialog { LcLog.warn("run of '" + toolId + "' failed on the host", e) outcome = [responseType: "FAILURE", error: e.class.simpleName + ": " + e.message, code: "host_error", trace: LcLog.trace(e)] } finally { + if (lease != null) { + if (outcome?.responseType == "CRASH") workers.discard(lease.worker) // a worker that died or was ended is never reused + workers.release(lease) + } + runLease = null LcLog.deleteTemp(tmp) } + if (outcome == null) outcome = [responseType: "FAILURE", error: "the run ended without an answer", code: "host_error"] + outcome.workerOutput = used?.stderrText() ?: "" if (inputs != null && outcome.code != LcConst.REFUSAL_CODE) { // a refusal never reached the worker: no record LcRun run = runSnapshot Map ended = LcRuns.finish(recordDir, new File(inputs["_job_dir"] as String), [entry: app.entry, app: appName, tool: toolId, inputs: inputs, outcome: outcome, seconds: (System.nanoTime() - t0) / 1e9, - stderr: worker?.stderrText(), runId: run?.runId, startedUtc: run?.startedUtc]) + stderr: outcome.workerOutput, runId: run?.runId, startedUtc: run?.startedUtc]) if (ended.problem) outcome.recordProblem = ended.problem else LcRetention.startPrune(app.entry as Map) // after the done markers: the Tools CLI removes what is past the quota, this script never does (SPEC 3.1) } @@ -2389,7 +2414,7 @@ class LcDialog { /** Cancel pressed: ask the worker to stop the run. */ void cancel() { status.text = "cancelling..." - worker?.cancel() + (runLease?.worker as LcWorker)?.cancel() } // ---- results @@ -2397,21 +2422,20 @@ class LcDialog { void finish(String appName, String toolLabel, Map outcome, double seconds) { showIdle() def type = outcome.responseType - lastReport = LcJson.pretty(outcome) + "\n\nworker output:\n" + (worker?.stderrText() ?: "") + lastReport = LcJson.pretty(outcome.findAll { k, v -> k != "workerOutput" }) + "\n\nworker output:\n" + (outcome.workerOutput ?: "") if (type == "COMPLETION") { finishCompleted(appName, toolLabel, outcome, seconds) } else if (type == "CANCELATION") { status.text = outcome.workerStopped ? "cancelled (worker stopped)" : "cancelled" // the Napari wording when the tool ignored Cancel and the worker was ended } else if (type == "CRASH") { - status.text = "the worker stopped: " + outcome.error - stopWorker(true) + status.text = "the worker stopped: " + outcome.error // runOnWorker already ended that worker (workers.discard) } else { finishFailed(toolLabel, outcome) } if (outcome.recordProblem) status.text = status.text + " (" + outcome.recordProblem + ")" // a record that could not be written is said, never dropped runSnapshot?.active = false if (!keepWorker.selected) stopWorker(false) - else if (worker != null) { idleTimer.duration = Duration.seconds(idleSeconds); idleTimer.playFromStart() } // the clock of an unused worker starts when its run ends + else if (workers.current != null) { idleTimer.duration = Duration.seconds(idleSeconds); idleTimer.playFromStart() } // the clock of an unused worker starts when its run ends } /** The idle timer ran out: the kept worker (a torch worker holds gigabytes) is closed; the next run starts a new one. */ @@ -2453,12 +2477,10 @@ class LcDialog { } // ---- worker lifetime and the Details window - /** Releases the worker: killed at once when `force`, else closed on a background thread. */ + /** Releases the kept worker: killed at once when `force`, else closed on a background thread, and only when no job holds it (a run in progress is not cut off by the idle timer or by a + * rescan; the person's "Restart worker" forces it). */ void stopWorker(boolean force) { - def w = worker - worker = null - if (w == null) return - if (force) w.kill() else Thread.start { w.close() } + workers.reap(force) } /** The last run's outcome and the worker's output in a read-only window. */ diff --git a/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LcLookups.groovy b/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LcLookups.groovy new file mode 100644 index 0000000..9fdcd05 --- /dev/null +++ b/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LcLookups.groovy @@ -0,0 +1,197 @@ +// ---------------------------------------------------------------------------------------------------- dynamic choices, asynchronous (SPEC_WRONG_SCIENCE section 5) +/** The ChoicesFrom lookups of one window. Every request is identified by (form generation, app, tool, parameter, the values it depends on, request id); a reply is applied only if ALL of + * those still hold when it arrives (5.2), so a late answer of another form, another app or an older question is dropped, with its error and its tooltip. The rules (5.1 - 5.6): + * - the form generation is process-wide and never reset: it grows whenever a form is built or destroyed (a tool or app is chosen, the window closes); the request id is per window and never reset; + * - a change of a value a parameter depends on invalidates at once (the latest id of the parameter moves on and its choices are marked pending); the pause before the new question + * (LcConst.CHOICES_DEBOUNCE_MS) only delays the question, never the invalidation; + * - at most one lookup is in flight per window (one thread, no thread per lookup), the waiting ones are coalesced (one slot per parameter, a newer question replaces an older one), at most + * LcConst.LOOKUP_PENDING_MAX wait (the oldest is dropped and logged) and a lookup that takes longer than LcConst.LOOKUP_TIMEOUT_SECONDS is ended with its worker; + * - a lookup runs on a worker leased from the window's LcWorkers (never a running tool's) and while a run is active it waits (LcConst.CHOICES_BUSY_RETRY_MS); + * - closing the window drops the waiting lookups, ends the one in flight and stops the thread. */ +class LcLookups { + /** Process-wide and never reset (5.1). */ + static final java.util.concurrent.atomic.AtomicInteger FORM_GENERATION = new java.util.concurrent.atomic.AtomicInteger(0) + private static final java.util.concurrent.atomic.AtomicInteger WINDOWS = new java.util.concurrent.atomic.AtomicInteger(0) + + final LcDialog dialog + private final java.util.concurrent.ExecutorService executor + private final Object lock = new Object() + private final Map waiting = new LinkedHashMap() // parameter -> its newest question that has not started + private final Map latest = [:] // parameter -> the id of the newest question issued + private final Set pendingParams = new HashSet() // parameters whose choices are not known now (a dependency changed) + private int nextId = 0 + private boolean drainQueued = false + private volatile Map inFlight = null + private final java.util.concurrent.atomic.AtomicInteger running = new java.util.concurrent.atomic.AtomicInteger(0) // lookups being answered right now (counted in `answer`, whoever calls it) + private volatile boolean closed = false + // what the tests observe + final java.util.concurrent.atomic.AtomicInteger maxInFlight = new java.util.concurrent.atomic.AtomicInteger(0) + final java.util.concurrent.atomic.AtomicInteger maxWaiting = new java.util.concurrent.atomic.AtomicInteger(0) + final java.util.concurrent.atomic.AtomicInteger started = new java.util.concurrent.atomic.AtomicInteger(0) + final java.util.concurrent.atomic.AtomicInteger dropped = new java.util.concurrent.atomic.AtomicInteger(0) + final List droppedReplies = Collections.synchronizedList(new ArrayList()) // parameter names whose reply was dropped as stale + final String threadName + + LcLookups(LcDialog dialog) { + this.dialog = dialog + this.threadName = "lc-lookups-" + WINDOWS.incrementAndGet() + this.executor = java.util.concurrent.Executors.newSingleThreadExecutor({ Runnable r -> Thread t = new Thread(r, threadName); t.daemon = true; return t } as java.util.concurrent.ThreadFactory) + } + + /** The time limit of one lookup: LcConst.LOOKUP_TIMEOUT_SECONDS, or the system property when it holds a positive whole number (the tests shorten it; anything else is logged once and ignored). */ + static int timeoutSeconds() { + String text = System.getProperty(LcConst.LOOKUP_TIMEOUT_PROPERTY) + if (!text) return LcConst.LOOKUP_TIMEOUT_SECONDS + try { + int value = Integer.parseInt(text.trim()) + if (value > 0) return value + } catch (NumberFormatException e) { + LcLog.once("lookup-timeout-text:" + text, "system property " + LcConst.LOOKUP_TIMEOUT_PROPERTY + " = '" + text + "' is not a whole number (" + e.message + ")") + } + LcLog.once("lookup-timeout:" + text, "system property " + LcConst.LOOKUP_TIMEOUT_PROPERTY + " = '" + text + "' is ignored: the default of " + LcConst.LOOKUP_TIMEOUT_SECONDS + " s is used") + return LcConst.LOOKUP_TIMEOUT_SECONDS + } + + /** A form was built or destroyed: the generation moves on (5.1) and the questions that wait belong to a form that is gone. */ + static int newGeneration() { return FORM_GENERATION.incrementAndGet() } + + /** Forget what waits (the form it belonged to is gone). The question in flight, if any, is dropped when it answers. */ + void forgetWaiting() { synchronized (lock) { waiting.clear(); pendingParams.clear() } } + + /** The id of the newest question issued for `param` (0 when none). */ + int latestId(String param) { synchronized (lock) { return latest[param] ?: 0 } } + + boolean isPending(String param) { synchronized (lock) { return pendingParams.contains(param) } } + + int inFlightCount() { return inFlight != null ? 1 : 0 } + + /** A value that `param` depends on changed: its newest id moves on, so every older question (waiting, in flight or answered) is stale from now. FX thread. */ + void invalidate(String param) { + synchronized (lock) { + latest[param] = ++nextId + waiting.remove(param) + } + } + + /** The choices of `param` are not known now (they are shown as pending until a current answer arrives). */ + void markPending(String param) { synchronized (lock) { pendingParams << param } } + + /** Asks for the choices of `param`. `request` is what the source tool receives (the values the parameter depends on); it is also the "dependencies" of the question. */ + void ask(Map p, Map request) { + if (closed) return + Map entry + synchronized (lock) { + int id = ++nextId + latest[p.name as String] = id + entry = [gen: FORM_GENERATION.get(), app: dialog.appBox.value, tool: dialog.currentTool?.id, param: p.name, source: p.choices_from, deps: LcJson.toJson(request), request: new LinkedHashMap(request), id: id, app_map: dialog.apps[dialog.appBox.value]] + waiting.remove(p.name as String) // a newer question replaces an older one that has not started (5.4); the new one goes to the end + waiting[p.name as String] = entry + while (waiting.size() > LcConst.LOOKUP_PENDING_MAX) { + String oldest = waiting.keySet().first() + waiting.remove(oldest) + dropped.incrementAndGet() + LcLog.warn("choices lookup of '" + oldest + "' dropped: more than " + LcConst.LOOKUP_PENDING_MAX + " lookups were waiting") + } + raise(maxWaiting, waiting.size()) + if (!drainQueued) { drainQueued = true; executor.execute({ -> drain() } as Runnable) } + } + } + + private static void raise(java.util.concurrent.atomic.AtomicInteger counter, int value) { + int seen = counter.get() + while (value > seen && !counter.compareAndSet(seen, value)) seen = counter.get() + } + + /** The lookup thread: takes the oldest waiting question and answers it, until none waits. */ + private void drain() { + while (!closed) { + Map next + synchronized (lock) { + if (waiting.isEmpty()) { drainQueued = false; return } + String first = waiting.keySet().first() + next = waiting.remove(first) + inFlight = next + } + try { answer(next) } + catch (Exception e) { // broad on purpose (the thread of the window must survive whatever a source tool or its worker does): the lookup ends with its text field + LcLog.warn("choices of '" + next.param + "' could not be loaded", e) + reply(next, null, e.class.simpleName + ": " + e.message) + } finally { inFlight = null } + } + synchronized (lock) { drainQueued = false } + } + + /** One lookup: stale ones are not even started; a run in progress makes it wait; otherwise the source tool runs on a leased worker with the time limit. */ + private void answer(Map req) { + raise(maxInFlight, running.incrementAndGet()) + try { answerNow(req) } finally { running.decrementAndGet() } + } + + private void answerNow(Map req) { + if (!current(req, false)) return // another form, another question, or other values already: nothing to ask + if (dialog.running) { retryLater(); return } + LcLease lease = dialog.workers.lease(req.app as String, req.app_map as Map, LcWorkers.LOOKUP) + if (lease == null) { retryLater(); return } // a run is active: lookups wait + File tmp = Files.createTempDirectory("lcqchoices_").toFile() + List options = null + String why = null + try { + started.incrementAndGet() + Map request = new LinkedHashMap(req.request as Map) + request["_job_dir"] = tmp.absolutePath + Map source = req.source as Map + Map outcome = (lease.worker as LcWorker).run(source.tool as String, request, null, timeoutSeconds()) + if (outcome.responseType == "COMPLETION") { + Object found = (outcome.outputs?.results ?: []).find { it.type == "values" && it.values?.get(source.field ?: "choices") instanceof List } + options = found?.values?.get(source.field ?: "choices")?.collect { it.toString() } + if (options == null) why = "the source tool '" + source.tool + "' did not return a list" + } else { + why = "the source tool '" + source.tool + "' did not complete (" + outcome.responseType + ": " + outcome.error + ")" + if (outcome.responseType == "CRASH") dialog.workers.discard(lease.worker) + } + } finally { + dialog.workers.release(lease) + LcLog.deleteTemp(tmp) + } + reply(req, options, why) + } + + private void retryLater() { + Platform.runLater { if (!closed) dialog.scheduleChoices(LcConst.CHOICES_BUSY_RETRY_MS) } + } + + /** Hands the answer to the FX thread, which applies it only if the question is still the current one. */ + private void reply(Map req, List options, String why) { + Platform.runLater { apply(req, options, why) } + } + + /** True when the question is still the one the form wants: same form generation, app, tool, newest id of the parameter and (when `values`) the dependencies as they are NOW. */ + private boolean current(Map req, boolean values) { + if (closed || req.gen != FORM_GENERATION.get()) return false + synchronized (lock) { if (latest[req.param as String] != req.id) return false } + if (!values) return true + Map p = dialog.currentTool?.inputs?.find { it.name == req.param } + if (p == null || dialog.appBox.value != req.app || dialog.currentTool.id != req.tool) return false + Map now = dialog.choiceRequest(p) + return now != null && LcJson.toJson(now) == req.deps + } + + /** FX thread: applies an answer when ALL of its identity still holds, else drops it whole (its error and tooltip too). */ + void apply(Map req, List options, String why) { + if (!current(req, true)) { droppedReplies << (req.param as String); return } + synchronized (lock) { pendingParams.remove(req.param as String) } + dialog.applyChoices(req.param as String, options, why) + } + + /** The window is closed: waiting lookups are dropped and the thread stops (the one in flight ends with its worker, which the window closes). */ + void close() { + closed = true + synchronized (lock) { waiting.clear(); pendingParams.clear() } + executor.shutdownNow() + } + + /** True when the lookup thread is gone (tests). */ + boolean stopped() { return executor.isTerminated() } + + boolean awaitStopped(long millis) { return executor.awaitTermination(millis, TimeUnit.MILLISECONDS) } +} diff --git a/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LcWorkers.groovy b/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LcWorkers.groovy new file mode 100644 index 0000000..c9623f8 --- /dev/null +++ b/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/LcWorkers.groovy @@ -0,0 +1,183 @@ +// ---------------------------------------------------------------------------------------------------- who owns the worker (SPEC_WRONG_SCIENCE 5.5, 5.6) +/** What the controller needs from a worker process: LcWorker implements it, the tests use a fake. */ +interface LcWorkerLike { + boolean alive() + + void close() + + void kill() +} + +/** One job's hold on a worker. A job (a run or a lookup) takes a lease when it is submitted, uses ONLY that worker and gives the lease back when it ends: the worker the job started on is the + * worker it ends on, whatever else happens to the form meanwhile (an app switch, a rescan, a second lookup). */ +class LcLease { + final LcWorkerLike worker + final String appName + final String kind + + LcLease(LcWorkerLike worker, String appName, String kind) { + this.worker = worker + this.appName = appName + this.kind = kind + } +} + +/** The ONE owner of the workers of a window: every start, reuse, release, reap and close goes through here, under one lock, so that each worker is owned once and closed once (5.5, 5.6). + * - At most one worker is kept for reuse (`current`, the one of the last app used). A lease for another app starts a new worker and retires the old one: it is closed at once when nothing holds it, + * else when its last lease ends. An app switch during a run therefore never replaces the run's worker: the run goes on with its own, the new app's worker is made for the next job. + * - A lookup never uses a worker a run is using and never starts while a run is active (`lease` answers null: the caller asks again later, LcConst.CHOICES_BUSY_RETRY_MS); a run waits for a + * lookup that holds the worker it needs (a lookup is short and has its own time limit). + * - After `closeAll` nothing is leased and no worker is made; a worker that was being made then is closed by the thread that made it (under the same lock, so the two cannot interleave). */ +class LcWorkers { + static final String RUN = "run" + static final String LOOKUP = "lookup" + static final int WAIT_STEP_MS = 100 + /** Test hook (tests only): called with a named point of lease / release / reap / close, so a test can interleave threads at fixed places. */ + static volatile Closure point = null + /** Test hook (tests only): called while a new worker is being made, before it is stored. */ + static volatile Closure creating = null + + private final Object lock = new Object() + private final Closure factory // (app map) -> a new worker + private LcWorkerLike current // kept for reuse + private String currentApp + private final Map leases = new IdentityHashMap() + private final Set retired = Collections.newSetFromMap(new IdentityHashMap()) + private final Set closedWorkers = Collections.newSetFromMap(new IdentityHashMap()) + private int runLeases = 0 + private boolean closed = false + final List closing = Collections.synchronizedList(new ArrayList()) // the threads that close workers (tests join them) + final List made = Collections.synchronizedList(new ArrayList()) // every worker this controller made (tests count them) + + LcWorkers(Closure factory) { this.factory = factory } + + private static void reach(String name) { + Closure hook = point + if (hook != null) hook.call(name) + } + + /** The worker kept for reuse, or null. */ + LcWorkerLike getCurrent() { synchronized (lock) { return current } } + + String getCurrentApp() { synchronized (lock) { return current != null ? currentApp : null } } + + boolean isClosed() { synchronized (lock) { return closed } } + + /** How many leases hold `worker` now. */ + int leasesOf(LcWorkerLike worker) { synchronized (lock) { return leases.get(worker) ?: 0 } } + + /** A lease on a live worker of the app `appName` (described by `app`, the registry map the factory reads), started when there is none. null for a LOOKUP while a run is active. Throws + * IllegalStateException when the window is closed. */ + LcLease lease(String appName, Map app, String kind) { + reach("lease:" + kind) + synchronized (lock) { + while (true) { + if (closed) throw new IllegalStateException("the window is closed") + if (kind == LOOKUP && runLeases > 0) return null + boolean reusable = current != null && currentApp == appName && current.alive() + if (reusable && kind == RUN && (leases.get(current) ?: 0) > 0) { // a lookup holds it: wait for it (it is short), then use the same worker + lock.wait(WAIT_STEP_MS) + continue + } + if (reusable) return take(current, appName, kind) + LcWorkerLike fresh = make(app) + LcWorkerLike old = current + current = fresh + currentApp = appName + if (old != null) retire(old) + return take(fresh, appName, kind) + } + } + } + + /** Called with the lock held. */ + private LcWorkerLike make(Map app) { + Closure hook = creating + if (hook != null) hook.call() + LcWorkerLike fresh = factory.call(app) + made << fresh + reach("lease:made") + return fresh + } + + private LcLease take(LcWorkerLike worker, String appName, String kind) { + leases.put(worker, (leases.get(worker) ?: 0) + 1) + if (kind == RUN) runLeases++ + return new LcLease(worker, appName, kind) + } + + /** Called with the lock held: a worker nobody holds is closed now, one that is held when its last lease ends. */ + private void retire(LcWorkerLike worker) { + if ((leases.get(worker) ?: 0) > 0) retired << worker else closeOnce(worker, false) + } + + /** Called with the lock held: closes `worker` on a thread of its own (a close waits for the process), at most once. */ + private void closeOnce(LcWorkerLike worker, boolean force) { + if (!closedWorkers.add(worker)) return + if (current.is(worker)) { current = null; currentApp = null } + Thread thread = new Thread({ -> if (force) worker.kill() else worker.close() } as Runnable, "lc-worker-close") + thread.daemon = true + closing << thread + thread.start() + } + + /** Gives a lease back. A retired worker whose last lease this was is closed; a worker that died (or was killed by a time limit) is dropped from the cache. */ + void release(LcLease lease) { + reach("release") + synchronized (lock) { + LcWorkerLike worker = lease.worker + int left = (leases.get(worker) ?: 1) - 1 + if (left > 0) leases.put(worker, left) else leases.remove(worker) + if (lease.kind == RUN) runLeases-- + if (left == 0 && retired.remove(worker)) closeOnce(worker, false) + else if (left == 0 && !worker.alive() && current.is(worker)) closeOnce(worker, true) + lock.notifyAll() + } + } + + /** Ends a worker that crashed or must not be used again: killed, and out of the cache, even while the lease on it is still held (the lease holder is finished with it). */ + void discard(LcWorkerLike worker) { + synchronized (lock) { + if (closedWorkers.contains(worker)) return + retired.remove(worker) + closeOnce(worker, true) + lock.notifyAll() + } + } + + /** The reaper (idle timer, Restart worker): closes the kept worker when no job holds it. With `force` it is killed, held or not. Returns true when a worker was ended. */ + boolean reap(boolean force) { + reach("reap") + synchronized (lock) { + if (current == null) return false + if (!force && (leases.get(current) ?: 0) > 0) return false + closeOnce(current, force) + lock.notifyAll() + return true + } + } + + /** The window is closed: no lease is given any more, every worker is closed (killed with `force`), held or not. A job that holds one of them ends with the worker. */ + void closeAll(boolean force) { + reach("close") + synchronized (lock) { + closed = true + List every = new ArrayList() + if (current != null) every << current + every.addAll(leases.keySet()) + every.addAll(retired) + every.each { LcWorkerLike w -> closeOnce(w, force) } + current = null + currentApp = null + retired.clear() + lock.notifyAll() + } + } + + /** Waits for the threads that close workers (at most `millis` for all of them); true when none is alive any more. */ + boolean joinClosing(long millis) { + long end = System.currentTimeMillis() + millis + for (Thread thread : new ArrayList(closing)) thread.join(Math.max(1L, end - System.currentTimeMillis())) + return closing.every { !it.alive } + } +} diff --git a/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/parts.txt b/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/parts.txt index 740de51..a87a433 100644 --- a/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/parts.txt +++ b/src/main/resources/org/cellmigrationlab/labconstrictor/qupath/parts.txt @@ -1,5 +1,7 @@ LabConstrictorTools.groovy LcCompat.groovy +LcLookups.groovy LcNumbers.groovy LcRetention.groovy LcRunIdentity.groovy +LcWorkers.groovy diff --git a/tests/gui_test_area_body.groovy b/tests/gui_test_area_body.groovy index 8a8cbd7..fdfc6f5 100644 --- a/tests/gui_test_area_body.groovy +++ b/tests/gui_test_area_body.groovy @@ -105,7 +105,7 @@ Thread.start("lc-gui-test") { // ---- (a) the size guard System.setProperty(PROP, "1000") // the fixture has 4800 pixels expect("budget_comes_from_the_property", LcArea.budget() == 1000L, LcArea.budget()) - fx { dlg.worker = null } + fx { dlg.stopWorker(true); null } // no worker is running (the guard must refuse before any is started) def jobsBefore = new File(LcRegistry.searchPath().first().parentFile, "results").list()?.length ?: 0 run(dlg) def refused = status(dlg) diff --git a/tests/gui_test_async_body.groovy b/tests/gui_test_async_body.groovy new file mode 100644 index 0000000..7788994 --- /dev/null +++ b/tests/gui_test_async_body.groovy @@ -0,0 +1,306 @@ +// ================================================================== GUI test body: asynchronous ChoicesFrom lookups and who owns the worker (SPEC_WRONG_SCIENCE section 5; ASY-01..08, QuPath #19) +// (appended to the tool script by tests/run_gui_test.sh). Needs the `runs` and `adv` test apps (registered by tests/ci_setup_apps.sh in LC_SHARED_HOME; copied into the scratch LC_HOME, a folder +// named lhome_async). The source tools of `runs` answer at once, wait for a GATE file the test creates (the dependency of the dropdown IS the gate path, so changing it is changing the dependency) +// or fail after it: that is the deterministic barrier of the fixtures, nothing depends on timing. The fixtures are read from conformance.json (async_lookups, limits.lookup_*). What each +// case checks is what the person would see (the dropdown's items and value, the status line, tooltips) and what the machine has (worker processes, threads), never only that no error occurred. +import java.util.concurrent.CountDownLatch +import java.util.concurrent.atomic.AtomicInteger + +class FakeWorker implements LcWorkerLike { + final AtomicInteger closes = new AtomicInteger(0), kills = new AtomicInteger(0) + volatile boolean up = true + boolean alive() { return up } + void close() { closes.incrementAndGet(); up = false } + void kill() { kills.incrementAndGet(); up = false } + int ends() { return closes.get() + kills.get() } +} + +def SHARED_HOME = new File(System.getenv("LC_SHARED_HOME") ?: System.getenv("LC_HOME")) +def results = [:] +def fx = { Closure c -> + if (Platform.isFxApplicationThread()) return c() + def latch = new CountDownLatch(1); def box = [null, null] + Platform.runLater { try { box[0] = c() } catch (Throwable t) { box[1] = t } finally { latch.countDown() } } + latch.await() + if (box[1] != null) throw box[1] + return box[0] +} +def expect = { String name, boolean ok, Object detail = "" -> + results[name] = ok + println((ok ? "PASS " : "FAIL ") + name + (ok ? "" : " -> " + detail)) +} +def waitFor = { Closure cond, int seconds = 60 -> def end = System.currentTimeMillis() + seconds * 1000; while (System.currentTimeMillis() < end) { if (cond()) return true; Thread.sleep(50) }; return false } +def waitIdle = { dlg, int seconds = 120 -> waitFor({ !dlg.running && fx { !dlg.runButton.disable } }, seconds) } +def status = { dlg -> fx { dlg.status.text } } +def pick = { dlg, app, tool -> fx { dlg.appBox.value = app }; fx { dlg.toolBox.value = tool }; Thread.sleep(300) } +def gateOf = { String name -> new File(System.getProperty("java.io.tmpdir"), "lc_gate_" + name + "_" + System.nanoTime()) } +def release = { File gate -> gate.text = "go" } +def settle = { LcRetention.pruneThreads*.join(20000); LcRetention.pruneThreads.clear() } +def alive = { Long pid -> pid != null && ProcessHandle.of(pid).map { it.isAlive() }.orElse(false) } + +Thread.start("lc-gui-test") { + try { + def home = new File(System.getenv("LC_HOME")); assert home.path.contains("lhome_async") + home.deleteDir(); def apps = new File(home, "apps"); apps.mkdirs() + def srcApps = new File(SHARED_HOME, "apps") + ["runs", "adv"].each { name -> + new File(apps, name + ".schema.json").text = new File(srcApps, name + ".schema.json").text + new File(apps, name + ".json").text = LcJson.toJson(LcJson.parse(new File(srcApps, name + ".json")) + [schema_path: new File(apps, name + ".schema.json").path]) + ["chmod", "644", new File(apps, name + ".json").path, new File(apps, name + ".schema.json").path].execute().waitFor() + } + def PY = (LcJson.parse(new File(apps, "runs.json")) as Map).python as String + LcRetention.pruneCommandFor = { Map entry -> [PY, "-c", "pass"] as List } + def confPath = System.getenv("LC_CONFORMANCE") ?: [PY, "-m", "labconstrictor_tools.conformance", "--path"].execute().text.trim() + def CONF = LcJson.parse(new File(confPath)) as Map + def asy = { String id -> def c = CONF.wrong_science.async_lookups.fixtures.find { it.id == id }; assert c != null : "conformance.json has no case " + id; c } + expect("conformance_has_ASY_01_to_08", (1..8).every { asy(String.format("ASY-%02d", it)) != null }, CONF.wrong_science.async_lookups.keySet()) + expect("the_limits_are_the_conformance_limits", LcConst.LOOKUP_IN_FLIGHT_MAX == CONF.limits.lookup_in_flight_max.value && LcConst.LOOKUP_PENDING_MAX == CONF.limits.lookup_pending_max.value && LcConst.LOOKUP_TIMEOUT_SECONDS == CONF.limits.lookup_timeout_s.value && + LcConst.CHOICES_DEBOUNCE_MS == CONF.limits.choices_debounce_ms.value && LcConst.CHOICES_BUSY_RETRY_MS == CONF.limits.choices_busy_retry_ms.value && LcConst.CLOSE_WAIT_SECONDS == CONF.limits.worker_close_timeout_s.value, CONF.limits) + expect("the_default_time_limit_of_a_lookup_is_60_s", LcLookups.timeoutSeconds() == 60, LcLookups.timeoutSeconds()) + + def dlg = fx { lcShow() } + Thread.sleep(800) + def combo = { String n -> fx { dlg.choiceBoxes[n] } } + def itemsOf = { String n -> fx { dlg.choiceBoxes[n].items.toList() } } + def shown = { String n -> fx { dlg.choiceBoxes[n].visible } } + def valueOf = { String n -> fx { dlg.choiceBoxes[n].value } } + def inFlight = { fx { dlg.lookups.inFlightCount() } } + def setText = { String n, String text -> fx { dlg.setters[n](text); null } } + def gen = { LcLookups.FORM_GENERATION.get() } + + // ---- the generation is process-wide and never reset; the request id is per window and never reset + def g0 = gen() + pick(dlg, "runs", "Tool B") + expect("generation_grows_whenever_a_form_is_built_or_destroyed", gen() >= g0 + 2, [g0, gen()]) + waitFor({ shown("mode") }, 30) + expect("a_lookup_at_form_build_gives_the_dropdown_its_choices", itemsOf("mode") == ["", "b1", "b2", "b3"] && valueOf("mode") == "", itemsOf("mode")) + def g1 = gen() + pick(dlg, "runs", "Tool A"); pick(dlg, "runs", "Tool B") + expect("generation_is_never_reset_by_a_second_window", gen() > g1 && { def other = fx { new LcDialog(null, [apps: [:], problems: []]) }; gen() > g1 }.call(), [g1, gen()]) + + // ---- ASY-01: A's lookup is delayed, switch to B, release A: B's dropdown shows only B's choices + def gateA = gateOf("asy01") + pick(dlg, "runs", "Tool A") + setText("gate", gateA.path) + expect("ASY-01_A_lookup_is_in_flight", waitFor({ inFlight() == 1 }, 30), inFlight()) + pick(dlg, "runs", "Tool B") + release(gateA) + expect("ASY-01_B_shows_its_own_choices", waitFor({ shown("mode") && itemsOf("mode") == ["", "b1", "b2", "b3"] }, 30), [shown("mode"), itemsOf("mode")]) + expect("ASY-01_no_choice_of_A_is_displayed_and_the_value_is_B_s", !itemsOf("mode").any { it.startsWith("a") } && valueOf("mode") == "" && fx { dlg.currentTool.id } == "tool_b", [itemsOf("mode"), valueOf("mode")]) + expect("ASY-01_A_s_reply_was_dropped", waitFor({ fx { dlg.lookups.droppedReplies.contains("mode") } }, 30), fx { dlg.lookups.droppedReplies.toList() }) + expect("ASY-01_the_form_is_B_s_and_not_pending", !fx { dlg.lookups.isPending("mode") }, "") + + // ---- ASY-02: change the dependency, release the old reply before the debounce fires: dropped, pending, no old choices + def gate1 = gateOf("asy02a"), gate2 = gateOf("asy02b"); release(gate2) + pick(dlg, "runs", "Tool A") + setText("gate", gate1.path) + waitFor({ inFlight() == 1 }, 30) + def droppedBefore = fx { dlg.lookups.droppedReplies.size() } + setText("gate", gate2.path) // the dependency changes now: the old question is stale at once + expect("ASY-02_the_parameter_is_pending_at_once", fx { dlg.lookups.isPending("mode") && !dlg.choiceBoxes["mode"].visible && dlg.choiceBoxes["mode"].items.isEmpty() }, [fx { dlg.lookups.isPending("mode") }, shown("mode")]) + release(gate1) // the old reply comes back before the debounce (400 ms) fires + expect("ASY-02_the_old_reply_is_dropped", waitFor({ fx { dlg.lookups.droppedReplies.size() } > droppedBefore }, 30), "") + expect("ASY-02_it_is_still_pending_and_shows_no_choices_after_the_old_reply", fx { dlg.lookups.isPending("mode") && !dlg.choiceBoxes["mode"].visible && dlg.choiceBoxes["mode"].items.isEmpty() }, [fx { dlg.lookups.isPending("mode") }, shown("mode"), itemsOf("mode")]) + expect("ASY-02_the_new_question_is_answered_for_the_new_value_and_not_the_old_one", waitFor({ shown("mode") && itemsOf("mode") == ["", "a1:" + gate2.name, "a2:" + gate2.name] }, 30) && !fx { dlg.lookups.isPending("mode") } && !itemsOf("mode").any { it.contains(gate1.name) }, [shown("mode"), itemsOf("mode")]) + + // ---- the five parts of a lookup's identity, one at a time (SPEC 5.2): a reply that differs from the current question in only ONE of them is dropped whole, the same reply with all five is applied + pick(dlg, "runs", "Tool B") + waitFor({ shown("mode") }, 30) + // a dropdown that was filled goes back to the text field at once when a value it depends on changes (the choices belong to the old value), and is filled again by the new answer + setText("key", "k2") + expect("ASY-02_a_filled_dropdown_is_pending_and_empty_at_once_when_its_dependency_changes", fx { dlg.lookups.isPending("mode") && !dlg.choiceBoxes["mode"].visible && dlg.choiceBoxes["mode"].items.isEmpty() && dlg.controls["mode"].visible && dlg.controls["mode"].tooltip?.text == "waiting for the choices..." }, [fx { dlg.lookups.isPending("mode") }, shown("mode"), itemsOf("mode")]) + expect("ASY-02_and_it_is_filled_again_by_the_answer_for_the_new_value", waitFor({ shown("mode") && itemsOf("mode") == ["", "b1", "b2", "b3"] && !fx { dlg.lookups.isPending("mode") } }, 30) && fx { dlg.controls["mode"].tooltip == null }, [shown("mode"), itemsOf("mode")]) + def modeParam = fx { dlg.currentTool.inputs.find { it.name == "mode" } } + def depsNow = fx { LcJson.toJson(dlg.choiceRequest(modeParam)) } + def reqOf = { Map over -> [gen: LcLookups.FORM_GENERATION.get(), app: "runs", tool: "tool_b", param: "mode", id: fx { dlg.lookups.latestId("mode") }, deps: depsNow] + over } + def tryApply = { Map req -> fx { int before = dlg.lookups.droppedReplies.size(); dlg.lookups.apply(req, ["zz1", "zz2"], null); [dropped: dlg.lookups.droppedReplies.size() > before, items: dlg.choiceBoxes["mode"].items.toList()] } } + def untouched = ["", "b1", "b2", "b3"] + def byGen = tryApply(reqOf([gen: LcLookups.FORM_GENERATION.get() - 1])) + expect("ASY-02_a_reply_of_another_form_generation_is_dropped", byGen.dropped && byGen.items == untouched, byGen) + def byApp = tryApply(reqOf([app: "adv"])) + expect("ASY-02_a_reply_of_another_app_is_dropped", byApp.dropped && byApp.items == untouched, byApp) + def byTool = tryApply(reqOf([tool: "tool_a"])) + expect("ASY-02_a_reply_of_another_tool_is_dropped", byTool.dropped && byTool.items == untouched, byTool) + def byId = tryApply(reqOf([id: fx { dlg.lookups.latestId("mode") } - 1])) + expect("ASY-02_a_reply_that_is_not_the_latest_request_of_the_parameter_is_dropped", byId.dropped && byId.items == untouched, byId) + def byDeps = tryApply(reqOf([deps: '{"key":"other"}'])) + expect("ASY-02_a_reply_for_other_dependencies_than_the_current_ones_is_dropped", byDeps.dropped && byDeps.items == untouched, byDeps) + def byParam = tryApply(reqOf([param: "nothing"])) + expect("ASY-02_a_reply_for_a_parameter_the_form_does_not_have_is_dropped", byParam.dropped && byParam.items == untouched, byParam) + def all = tryApply(reqOf([:])) + expect("ASY-02_the_same_reply_with_all_five_parts_current_is_applied", !all.dropped && all.items == ["", "zz1", "zz2"], all) + + // ---- ASY-03: A's lookup fails after the switch to B: no error and no tooltip on any B field + def gateF = gateOf("asy03") + pick(dlg, "runs", "Tool A that fails") + setText("gate", gateF.path) + waitFor({ inFlight() == 1 }, 30) + pick(dlg, "runs", "Tool B") + fx { dlg.status.text = "idle"; null } + def droppedBefore3 = fx { dlg.lookups.droppedReplies.size() } + release(gateF) + waitFor({ fx { dlg.lookups.droppedReplies.size() } > droppedBefore3 && inFlight() == 0 }, 30) + waitFor({ shown("mode") && itemsOf("mode") == ["", "b1", "b2", "b3"] }, 30) + Thread.sleep(500) + expect("ASY-03_no_error_in_the_status_line", !status(dlg).contains("could not be loaded") && !status(dlg).contains("source") && status(dlg) == "idle", status(dlg)) + expect("ASY-03_no_tooltip_on_any_field_of_B", fx { dlg.controls.values().every { it.tooltip == null } }, fx { dlg.controls.collectEntries { k, v -> [(k): v.tooltip?.text] } }) + expect("ASY-03_B_shows_its_own_choices", itemsOf("mode") == ["", "b1", "b2", "b3"], itemsOf("mode")) + expect("ASY-03_a_failure_for_the_current_form_is_still_said", { pick(dlg, "runs", "Tool A that fails"); release(gateF); setText("gate", gateF.path); waitFor({ status(dlg).contains("The choices for 'mode' could not be loaded") }, 30) }.call(), status(dlg)) + + // ---- ASY-04: 50 changes of a dependency in about a second: at most one lookup in flight, at most one waiting per parameter, at most one worker process started + pick(dlg, "runs", "Tool B") + waitFor({ shown("mode") }, 30) + def startedBefore = LcWorker.STARTED.get() + def lookupsBefore = fx { dlg.lookups.started.get() } + for (int i = 1; i <= 50; i++) { fx { dlg.setters["key"]("key" + i); null }; Thread.sleep(20) } + waitFor({ fx { dlg.lookups.started.get() } > lookupsBefore && inFlight() == 0 && shown("mode") }, 30) + expect("ASY-04_at_most_one_lookup_in_flight", fx { dlg.lookups.maxInFlight.get() } <= LcConst.LOOKUP_IN_FLIGHT_MAX, fx { dlg.lookups.maxInFlight.get() }) + expect("ASY-04_at_most_one_waiting_per_parameter", fx { dlg.lookups.maxWaiting.get() } <= 1, fx { dlg.lookups.maxWaiting.get() }) + expect("ASY-04_at_most_one_worker_process_started", LcWorker.STARTED.get() - startedBefore <= 1, LcWorker.STARTED.get() - startedBefore) + expect("ASY-04_the_changes_were_coalesced_into_few_questions", fx { dlg.lookups.started.get() } - lookupsBefore <= 5, fx { dlg.lookups.started.get() } - lookupsBefore) + expect("ASY-04_the_dropdown_has_the_choices_of_the_last_value", itemsOf("mode") == ["", "b1", "b2", "b3"] && !fx { dlg.lookups.isPending("mode") }, itemsOf("mode")) + // ... and without the pause: 12 questions for 12 parameters at once while one lookup is stuck: 8 wait, the oldest 4 are dropped and logged + def gateS = gateOf("asy04c") + pick(dlg, "runs", "Tool A"); setText("gate", gateS.path) + waitFor({ inFlight() == 1 }, 30) + def droppedFrom = fx { dlg.lookups.dropped.get() } + fx { (1..12).each { i -> dlg.lookups.ask([name: "x" + i, choices_from: [tool: "choices_b", field: "choices"]], [key: "k"]) }; null } + expect("ASY-04_at_most_8_lookups_wait_and_the_oldest_are_dropped", fx { dlg.lookups.maxWaiting.get() } == LcConst.LOOKUP_PENDING_MAX && fx { dlg.lookups.dropped.get() } - droppedFrom == 4, [fx { dlg.lookups.maxWaiting.get() }, fx { dlg.lookups.dropped.get() }]) + expect("ASY-04_the_drops_are_logged_oldest_first", ["x1", "x2", "x3", "x4"].every { n -> LcLog.recent.any { it.contains("choices lookup of '" + n + "' dropped") } } && !LcLog.recent.any { it.contains("choices lookup of 'x5' dropped") }, LcLog.recent.takeRight(5)) + release(gateS) + waitFor({ inFlight() == 0 && fx { dlg.lookups.inFlightCount() } == 0 }, 30); Thread.sleep(600) + + // ---- ASY-05: two parameters with ChoicesFrom, both requested at once: one worker process, each reply goes to its own control + fx { dlg.stopWorker(true); null }; Thread.sleep(500) + def started5 = LcWorker.STARTED.get() + pick(dlg, "runs", "Tool with two dropdowns") + expect("ASY-05_both_dropdowns_are_filled", waitFor({ shown("first") && shown("second") }, 30), [shown("first"), shown("second")]) + expect("ASY-05_each_reply_went_to_its_own_control", itemsOf("first") == ["", "b1", "b2", "b3"] && itemsOf("second") == ["", "c1", "c2"], [itemsOf("first"), itemsOf("second")]) + expect("ASY-05_never_more_than_one_lookup_at_a_time", fx { dlg.lookups.maxInFlight.get() } <= LcConst.LOOKUP_IN_FLIGHT_MAX, fx { dlg.lookups.maxInFlight.get() }) + expect("ASY-05_one_worker_process_served_both", LcWorker.STARTED.get() - started5 == 1 && fx { dlg.workers.made.size() } >= 1, LcWorker.STARTED.get() - started5) + + // ---- ASY-06: start a run, switch the app, request a lookup: the run keeps its worker to the end; the lookup runs after, in the new app's worker + pick(dlg, "runs", "Delayed values") + def gateR = gateOf("asy06"); fx { dlg.setters["gate"](gateR.path); null } + fx { dlg.run() } + waitFor({ fx { dlg.worker != null && dlg.worker.currentTask != null } }, 30) + def w1 = fx { dlg.worker }, pid1 = fx { dlg.worker.proc.pid() } + def startedRun = LcWorker.STARTED.get(), lookupsRun = fx { dlg.lookups.started.get() } + pick(dlg, "adv", "Pick") // its dropdowns ask at once; while a run is active they wait (1500 ms retries) + Thread.sleep(2300) + expect("ASY-06_the_run_still_has_its_worker_and_the_app_switch_did_not_replace_it", fx { dlg.worker.is(w1) && dlg.workerApp == "runs" && dlg.worker.alive() && dlg.running }, [fx { dlg.workerApp }, fx { dlg.running }]) + expect("ASY-06_no_lookup_started_and_no_worker_was_made_for_the_new_app_during_the_run", fx { dlg.lookups.started.get() } == lookupsRun && LcWorker.STARTED.get() == startedRun, [fx { dlg.lookups.started.get() }, lookupsRun, LcWorker.STARTED.get(), startedRun]) + release(gateR) + expect("ASY-06_the_run_ends_on_its_own_worker", waitIdle(dlg, 60) && status(dlg).startsWith("done"), status(dlg)); settle() + expect("ASY-06_the_lookup_then_runs_in_the_new_apps_worker", waitFor({ fx { dlg.workerApp == "adv" } && itemsOf("mode").size() > 100 }, 60), [fx { dlg.workerApp }, itemsOf("mode").size()]) + expect("ASY-06_the_new_worker_is_another_process_and_the_run_s_worker_was_closed", fx { dlg.worker.proc.pid() } != pid1 && waitFor({ !alive(pid1 as Long) }, 30), [fx { dlg.worker.proc.pid() }, pid1]) + expect("ASY-06_the_run_record_names_the_run_s_app", (LcJson.parse((new File(home, "runs").listFiles() as List).max { it.name }.with { new File(it, "run.json") }) as Map).with { app == "runs" && tool == "delayed_values" }, "") + + // ---- a lookup that takes longer than its limit is ended with its worker (5.4); the next one works on a new worker + System.setProperty(LcConst.LOOKUP_TIMEOUT_PROPERTY, "2") + expect("the_time_limit_comes_from_the_property", LcLookups.timeoutSeconds() == 2, LcLookups.timeoutSeconds()) + def gateT = gateOf("timeout") + pick(dlg, "runs", "Tool A"); setText("gate", gateT.path) + waitFor({ inFlight() == 1 }, 30) + def stuckPid = fx { dlg.worker.proc.pid() } + expect("lookup_over_the_limit_is_ended_and_said", waitFor({ status(dlg).contains("The choices for 'mode' could not be loaded") && status(dlg).contains("did not answer within 2 s") }, 30), status(dlg)) + expect("lookup_over_the_limit_discards_its_worker", waitFor({ !alive(stuckPid as Long) }, 30), stuckPid) + System.clearProperty(LcConst.LOOKUP_TIMEOUT_PROPERTY) + release(gateT) + pick(dlg, "runs", "Tool B") + expect("lookup_after_the_timeout_works_on_a_new_worker", waitFor({ shown("mode") && itemsOf("mode") == ["", "b1", "b2", "b3"] }, 30) && fx { dlg.worker.proc.pid() } != stuckPid, itemsOf("mode")) + + // ---- ASY-07: close the window with a lookup in flight: no worker process and no thread of the window stays; a worker being made while it closes does not survive + def dlg7 = fx { lcShow() } + Thread.sleep(800) + fx { dlg7.appBox.value = "runs" }; fx { dlg7.toolBox.value = "Tool A" }; Thread.sleep(300) + def gate7 = gateOf("asy07"); fx { dlg7.setters["gate"](gate7.path); null } + waitFor({ fx { dlg7.lookups.inFlightCount() } == 1 }, 30) + def pid7 = fx { dlg7.worker.proc.pid() }, thread7 = fx { dlg7.lookups.threadName }, started7 = LcWorker.STARTED.get() + fx { dlg7.stage.hide(); null } + expect("ASY-07_the_lookup_thread_stops", fx { dlg7.lookups.awaitStopped(20000) } && !Thread.getAllStackTraces().keySet().any { it.name == thread7 && it.alive }, thread7) + expect("ASY-07_the_worker_process_is_gone", waitFor({ !alive(pid7 as Long) }, 30) && fx { dlg7.workers.joinClosing(20000) } && fx { dlg7.workers.current == null && dlg7.workers.closed }, pid7) + expect("ASY-07_no_thread_that_closes_workers_is_left_and_nothing_was_started_after_the_close", !Thread.getAllStackTraces().keySet().any { it.name == "lc-worker-close" && it.alive } && LcWorker.STARTED.get() == started7, LcWorker.STARTED.get() - started7) + release(gate7) + def dlg7b = fx { lcShow() } + Thread.sleep(800) + LcWorkers.creating = { long end = System.currentTimeMillis() + 1500; while (System.currentTimeMillis() < end) { try { Thread.sleep(50) } catch (InterruptedException e) { /* the hook ignores interrupts: the worker IS made while the window closes */ } } } + fx { dlg7b.appBox.value = "runs" }; fx { dlg7b.toolBox.value = "Tool B" } + Thread.sleep(700) // the lookup thread is inside the creation of its worker + fx { dlg7b.stage.hide(); null } + LcWorkers.creating = null + def made7 = fx { dlg7b.workers.made.toList() } + expect("ASY-07_a_worker_made_while_the_window_closed_does_not_survive", waitFor({ fx { dlg7b.workers.joinClosing(20000) } && fx { dlg7b.workers.made.every { !it.alive() } } }, 40) && fx { dlg7b.workers.closed }, made7.size()) + expect("ASY-07_the_lookup_thread_of_that_window_stops_too", fx { dlg7b.lookups.awaitStopped(20000) }, "") + + // ---- ASY-08: acquire / release / reap / close from three threads with barriers: the cache stays consistent (each worker owned once), no double close + // (a) fixed interleaving: T1 holds A (a lookup), T2 leases B for a run (A is retired, not closed: it is held), T3 closes the window while T1 is about to release + def ws = new LcWorkers({ Map app -> new FakeWorker() } as Closure) + def leaseA = ws.lease("A", [:], LcWorkers.LOOKUP) + def a = leaseA.worker as FakeWorker + def t2Done = new CountDownLatch(1), t1AtRelease = new CountDownLatch(1), t3Done = new CountDownLatch(1) + LcWorkers.point = { String point -> if (point == "release") { t1AtRelease.countDown(); t2Done.await(); } } + def leaseB = null + def t1 = Thread.start { ws.release(leaseA) } // T1 stops at "release" until T2 has its lease + t1AtRelease.await() + def t2 = Thread.start { LcWorkers.point = null; leaseB = ws.lease("B", [:], LcWorkers.RUN); t2Done.countDown() } + t2.join(10000); t1.join(10000) + def b = leaseB.worker as FakeWorker + expect("ASY-08_the_old_worker_was_closed_once_when_its_last_lease_ended", ws.joinClosing(5000) && a.ends() == 1 && !a.alive() && b.ends() == 0 && b.alive() && ws.current.is(b) && ws.currentApp == "B", [a.ends(), b.ends()]) + def t3 = Thread.start { ws.closeAll(false); t3Done.countDown() } + t3.join(10000) + ws.release(leaseB) // the lease of the closed window ends later: nothing is closed twice + ws.closeAll(false) + expect("ASY-08_closing_twice_and_releasing_after_the_close_close_nothing_twice", ws.joinClosing(5000) && b.ends() == 1 && a.ends() == 1 && ws.leasesOf(b) == 0 && ws.current == null, [a.ends(), b.ends()]) + def refusedAfterClose = null + try { ws.lease("A", [:], LcWorkers.LOOKUP) } catch (IllegalStateException e) { refusedAfterClose = e.message } + expect("ASY-08_no_lease_is_given_after_the_close", refusedAfterClose == "the window is closed" && ws.made.size() == 2, refusedAfterClose) + // (b) a lookup never uses or replaces a run's worker and never starts while a run is active; a run waits for the lookup that holds its worker + def ws2 = new LcWorkers({ Map app -> new FakeWorker() } as Closure) + def runLease = ws2.lease("A", [:], LcWorkers.RUN) + expect("ASY-08_no_lookup_lease_while_a_run_is_active", ws2.lease("A", [:], LcWorkers.LOOKUP) == null && ws2.lease("B", [:], LcWorkers.LOOKUP) == null && ws2.made.size() == 1, ws2.made.size()) + def other = ws2.lease("B", [:], LcWorkers.RUN) // a run for another app: its own worker, the first is retired and still held + expect("ASY-08_an_app_switch_does_not_close_the_worker_a_run_holds", (runLease.worker as FakeWorker).ends() == 0 && (runLease.worker as FakeWorker).alive() && ws2.current.is(other.worker), "") + ws2.release(runLease); ws2.release(other) + expect("ASY-08_the_retired_worker_is_closed_when_the_run_ends", ws2.joinClosing(5000) && (runLease.worker as FakeWorker).ends() == 1 && (other.worker as FakeWorker).ends() == 0, "") + def lookupLease = ws2.lease("B", [:], LcWorkers.LOOKUP) + def runWaits = new CountDownLatch(1), gotRun = [null] + def tr = Thread.start { runWaits.countDown(); gotRun[0] = ws2.lease("B", [:], LcWorkers.RUN) } + runWaits.await(); Thread.sleep(400) + expect("ASY-08_a_run_waits_for_the_lookup_that_holds_its_worker", gotRun[0] == null && tr.alive, gotRun[0]) + ws2.release(lookupLease); tr.join(10000) + expect("ASY-08_then_it_uses_the_same_worker", gotRun[0] != null && gotRun[0].worker.is(lookupLease.worker) && ws2.made.size() == 2, ws2.made.size()) + ws2.release(gotRun[0] as LcLease) + expect("ASY-08_reap_closes_an_idle_worker_and_not_a_held_one", { def held = ws2.lease("B", [:], LcWorkers.LOOKUP); def r1 = ws2.reap(false); ws2.release(held); def r2 = ws2.reap(false); ws2.joinClosing(5000); !r1 && r2 && (held.worker as FakeWorker).ends() == 1 && ws2.current == null }.call(), "") + ws2.closeAll(true) + // (c) stress: three threads lease, release, reap and close at random; nothing is closed twice or while held, and no worker is lost + def ws3 = new LcWorkers({ Map app -> new FakeWorker() } as Closure) + def failures = Collections.synchronizedList([]) + def workersThreads = (1..3).collect { int n -> + Thread.start { + def rnd = new Random(1000 + n) + for (int i = 0; i < 300; i++) { + try { + int op = rnd.nextInt(10) + if (op < 6) { + def l = ws3.lease(rnd.nextBoolean() ? "A" : "B", [:], n == 1 ? LcWorkers.RUN : LcWorkers.LOOKUP) + if (l != null) { + if ((l.worker as FakeWorker).ends() != 0) failures << "a leased worker was already closed" + Thread.yield() + if ((l.worker as FakeWorker).ends() != 0 && !ws3.closed) failures << "a worker was closed while it was held" + ws3.release(l) + } + } else if (op < 9) ws3.reap(false) + else if (n == 3 && i > 250) ws3.closeAll(false) + } catch (IllegalStateException e) { /* closed window: leases are refused */ } + } + } + } + workersThreads*.join(60000) + ws3.closeAll(false) + expect("ASY-08_stress_nothing_failed_and_every_worker_was_closed_exactly_once", failures.isEmpty() && ws3.joinClosing(10000) && ws3.made.every { (it as FakeWorker).ends() == 1 } && ws3.made.every { ws3.leasesOf(it) == 0 }, [failures.take(3), ws3.made.collect { (it as FakeWorker).ends() }.unique()]) + LcWorkers.point = null + + } catch (Throwable t) { t.printStackTrace(); results["EXCEPTION"] = false } + finally { println "RESULTS " + results; Thread.sleep(500); System.exit(results && results.every { it.value } ? 0 : 1) } +} diff --git a/tests/gui_test_conformance_body.groovy b/tests/gui_test_conformance_body.groovy new file mode 100644 index 0000000..cfa8234 --- /dev/null +++ b/tests/gui_test_conformance_body.groovy @@ -0,0 +1,124 @@ +// ================================================================== GUI test body: conformance.json of labconstrictor-tools (docs/CONFORMANCE.md, "How each host's test loads it") +// (appended to the tool script by tests/run_gui_test.sh). The file is the one of the installed labconstrictor-tools: LC_CONFORMANCE, else `python -m labconstrictor_tools.conformance --path` of a +// registered app. This body checks what needs no GUI: the file validates; every LIMIT and every MESSAGE template and every host-internal CODE of the file is compared with this script's own constant +// (a key this script does not know FAILS: a new rule is a failing test until the host has followed it); the COMPAT vectors run through LcCompat.check; the DONE-STATUS rule runs through the real +// Tools helper from every way a run can end; and a LEDGER says, for every fixture id of the file, which body runs it or why it does not apply to QuPath (an id that is in the file and not in the +// ledger FAILS; a case that cannot run here is printed as UNSUPPORTED, never passed). The fixtures themselves are run by the other bodies, through the real dialog. +import java.util.concurrent.CountDownLatch + +def SHARED_HOME = new File(System.getenv("LC_SHARED_HOME") ?: System.getenv("LC_HOME")) +def results = [:] +def expect = { String name, boolean ok, Object detail = "" -> + results[name] = ok + println((ok ? "PASS " : "FAIL ") + name + (ok ? "" : " -> " + detail)) +} + +Thread.start("lc-gui-test") { + try { + def interactions = (new File(SHARED_HOME, "apps").listFiles({ File f -> f.name.endsWith(".json") && !f.name.endsWith(".schema.json") } as FileFilter) as List).sort { it.name }.collect { LcJson.parse(it) as Map }.first() + def PY = interactions.python as String + def confPath = System.getenv("LC_CONFORMANCE") ?: [PY, "-m", "labconstrictor_tools.conformance", "--path"].execute().text.trim() + def CONF = LcJson.parse(new File(confPath)) as Map + def validate = new ProcessBuilder([PY, "-m", "labconstrictor_tools.conformance", "--validate"]).redirectErrorStream(true).start() + def validateText = validate.inputStream.getText("UTF-8"); validate.waitFor() + expect("the_file_validates_with_the_tools_schema", validate.exitValue() == 0, validateText.take(400)) + expect("the_file_has_the_sections_this_host_reads", ["conformance", "limits", "host_internal_codes", "messages", "compat", "wrong_science"].every { CONF.containsKey(it) } && CONF.conformance == 1L, CONF.keySet()) + + // ---- limits: every key is compared with a constant of this script, or is listed as not this host's (it never prunes) + def LIMITS = [ + form_integer_max: LcConst.FORM_INTEGER_LIMIT, unbounded_float: LcConst.FLOAT_LIMIT, choices_debounce_ms: LcConst.CHOICES_DEBOUNCE_MS, choices_busy_retry_ms: LcConst.CHOICES_BUSY_RETRY_MS, + worker_close_timeout_s: LcConst.CLOSE_WAIT_SECONDS, lookup_in_flight_max: LcConst.LOOKUP_IN_FLIGHT_MAX, lookup_pending_max: LcConst.LOOKUP_PENDING_MAX, lookup_timeout_s: LcConst.LOOKUP_TIMEOUT_SECONDS] + def NOT_THIS_HOST = [results_kept: "retention belongs to `labconstrictor-tools prune`; this host never prunes", run_records_kept: "retention belongs to `labconstrictor-tools prune`; this host never prunes"] + CONF.limits.each { String key, Map entry -> + if (LIMITS.containsKey(key)) expect("limit_" + key + "_is_the_conformance_value", (LIMITS[key] as double) == (entry.value as double), [LIMITS[key], entry.value]) + else if (NOT_THIS_HOST.containsKey(key)) println "UNSUPPORTED limit_" + key + ": " + NOT_THIS_HOST[key] + else expect("limit_" + key + "_is_known_to_this_host", false, "the file has a limit this host does not know: " + key) + } + expect("every_limit_this_host_keeps_is_in_the_file", LIMITS.keySet().every { CONF.limits.containsKey(it) } && NOT_THIS_HOST.keySet().every { CONF.limits.containsKey(it) }, CONF.limits.keySet()) + expect("the_quotas_prune_applies_are_20_and_50", CONF.limits.results_kept.value == 20 && CONF.limits.run_records_kept.value == 50) + expect("the_lookup_limits_are_marked_default_not_decided", ["lookup_in_flight_max", "lookup_pending_max", "lookup_timeout_s"].every { CONF.limits[it].status == "default" } && CONF.limits.choices_debounce_ms.status == "decided", "") + + // ---- host-internal codes + def CODES = [selection_mixed_planes: LcConst.CODE_SELECTION_MIXED_PLANES, selection_plane_mismatch: LcConst.CODE_SELECTION_PLANE_MISMATCH, target_unavailable: LcRun.CODE_TARGET_UNAVAILABLE] + CONF.host_internal_codes.each { String code -> + if (CODES.containsKey(code)) expect("code_" + code + "_is_the_conformance_code", CODES[code] == code) + else if (code == "frame_unsupported_transform") println "UNSUPPORTED code_frame_unsupported_transform: a QuPath image has no spatial transform, so the code is never produced" + else expect("code_" + code + "_is_known_to_this_host", false, "the file has a code this host does not know: " + code) + } + + // ---- messages: every template of the file is the text this script produces, or is listed as not this host's + def MESSAGES = [ + size_mismatch: LcConst.SIZE_MISMATCH_TEMPLATE, calibration_required: LcUnits.CALIBRATION_REQUIRED, calibration_unknown_tooltip: LcUnits.NOT_CALIBRATED_TOOLTIP, unit_damaged_warning: LcUnits.DAMAGED_TEXT, + pixels_not_square_warning: LcUnits.NOT_SQUARE_TEXT, unit_not_length_warning: LcUnits.NOT_A_LENGTH_TEXT, target_unavailable: LcRun.TARGET_UNAVAILABLE, target_changed_size: LcRun.TARGET_CHANGED_SIZE, + number_above_maximum: LcNumbers.ABOVE_TEMPLATE, number_below_minimum: LcNumbers.BELOW_TEMPLATE, file_input_note: LcConst.FILE_INPUT_NOTE] + def MESSAGES_NOT_HERE = [refusal_suffix: "'(nothing was changed; files are in {path})' follows a refusal that leaves files; every refusal of this host happens before the results folder exists, so no refusal has files"] + CONF.messages.each { String key, Map entry -> + if (MESSAGES.containsKey(key)) expect("message_" + key + "_is_the_conformance_template", MESSAGES[key] == entry.template, [MESSAGES[key], entry.template]) + else if (MESSAGES_NOT_HERE.containsKey(key)) println "UNSUPPORTED message_" + key + ": " + MESSAGES_NOT_HERE[key] + else expect("message_" + key + "_is_known_to_this_host", false, "the file has a message this host does not know: " + key) + } + expect("every_message_this_host_uses_is_in_the_file", MESSAGES.keySet().every { CONF.messages.containsKey(it) }, CONF.messages.keySet()) + + // ---- compat: the vectors of PROTOCOL.md, through LcCompat.check with the file's own minimum and required features + def minVersion = CONF.compat.min_tools_version as String, required = CONF.compat.required_features as List + CONF.compat.cases.each { Map c -> + def got = LcCompat.check(c.schema as Map, minVersion, required) + expect("compat_" + (c.case as String).replace(" ", "_").replace("(", "").replace(")", "") + "_is_" + c.expect, got.status == c.expect && (!c.reason_contains || got.reason.contains(c.reason_contains as String)) && (c.expect != "ok" || got.reason == ""), got) + } + def order = CONF.compat.version_order as List + expect("compat_the_version_order_is_ascending", (0..<(order.size() - 1)).every { LcCompat.compareVersions(LcCompat.parseVersion(order[it]), LcCompat.parseVersion(order[it + 1])) < 0 } && order.every { LcCompat.parseVersion(it) != null }, order) + expect("compat_a_text_that_is_no_version_is_not_parsed", LcCompat.parseVersion("banana") == null && LcCompat.parseVersion(null) == null && LcCompat.parseVersion(5) == null && LcCompat.parseVersion("v0.2.0-rc.1") == LcCompat.parseVersion("0.2.0rc1"), "") + expect("compat_this_host_needs_what_the_run_record_and_prune_need", LcCompat.MIN_TOOLS_VERSION == "0.3.0a1" && LcCompat.REQUIRED_FEATURES == ["reserved_inputs_v1", "retention_prune"], "") + def odd = null + try { LcCompat.check([protocol: 1L], "banana", []) } catch (IllegalArgumentException e) { odd = e.message } + expect("compat_a_minimum_that_is_no_version_is_a_programming_error", odd != null && odd.contains("programming error"), odd) + expect("compat_warn_below_gives_a_warning_not_a_refusal", LcCompat.check([protocol: 1L, tools_version: "0.3.0a1", features: required], minVersion, required, "0.4.0").status == "warn" && LcCompat.check([protocol: 1L, tools_version: "0.4.0", features: required], minVersion, required, "0.4.0").status == "ok", "") + expect("compat_the_apps_this_machine_registered_pass_it", LcRegistry.load().with { reg -> !reg.apps.isEmpty() && reg.apps.values().every { LcCompat.checkApp(it as Map).status == "ok" } }, "") + + // ---- done status: the rule of the file through the real Tools helper, from every way a run can end + def RET = CONF.wrong_science.retention + def ends = [ + [name: "complete", outcome: [responseType: "COMPLETION"], expect: RET.done_status.by_task_status.COMPLETE], + [name: "cancelled_by_the_tool", outcome: [responseType: "CANCELATION", cancelRequested: true], expect: RET.done_status.by_task_status.CANCELED], + [name: "failed", outcome: [responseType: "FAILURE", error: "x", cancelRequested: false], expect: RET.done_status.by_task_status.FAILED], + [name: "failed_even_after_cancel", outcome: [responseType: "FAILURE", error: "x", cancelRequested: true], expect: RET.done_status.by_task_status.FAILED], + [name: "crashed_without_cancel", outcome: [responseType: "CRASH", error: "x", cancelRequested: false], expect: RET.done_status.by_task_status.CRASHED], + [name: "crashed_after_cancel_is_the_worker_killed_after_the_grace_period", outcome: [responseType: "CANCELATION", workerStopped: true, error: "x", cancelRequested: true], expect: RET.done_status.crashed_after_cancel]] + ends.each { Map end -> + def built = LcRunRecord.build(interactions, LcRunRecord.request([app: "interactions", tool: "t", inputs: [:], outcome: end.outcome, seconds: 0d, stderr: "", runId: "r", startedUtc: "2026-10-13T00:00:00.000Z"])) + expect("done_status_" + end.name + "_is_" + end.expect, built.doneStatus == end.expect, built) + } + expect("done_status_the_file_names_the_four_task_statuses", RET.done_status.by_task_status.keySet() as Set == ["COMPLETE", "CANCELED", "FAILED", "CRASHED"] as Set && RET.done_status.crashed_after_cancel == "cancelled", RET.done_status) + + // ---- the ledger: every fixture id of the file is run by a body of this repository, or is said not to apply + def WS = CONF.wrong_science + def idsOf = { List list -> list.collect { it.id as String } } + def fileIds = (idsOf(WS.coordinate.cases) + idsOf(WS.calibration.cases) + idsOf(WS.calibration.transitions) + idsOf(WS.retention.fixtures_tools) + idsOf(WS.retention.fixtures_hosts) + + idsOf(WS.run_identity.fixtures) + idsOf(WS.async_lookups.fixtures) + idsOf(WS.number_limits.cases)) as List + def LEDGER = [:] + ["CF-01", "CF-07", "CF-08", "CF-09", "CF-10", "CF-11", "CF-12", "CF-14"].each { LEDGER[it] = "gui_test_frame_body" } + ["CF-02", "CF-03", "CF-04", "CF-05", "CF-06", "CF-13"].each { LEDGER[it] = "n/a: QuPath has no spatial transform (README, coordinate frame)" } + (1..20).each { LEDGER[String.format("CAL-%02d", it)] = "gui_test_calibration_body" } + (1..9).each { LEDGER["CAL-T" + it] = "gui_test_calibration_body" } + [1, 2, 3, 4, 5, 6, 7, 8, 9, 12, 13].each { LEDGER[String.format("RET-%02d", it)] = "n/a: run against Tools' retention.prune and its CLI, not a host test (the real prune is exercised by gui_test_retention_body)" } + (1..6).each { LEDGER[String.format("HRET-%02d", it)] = "gui_test_retention_body" } + ["RET-10", "RET-11"].each { LEDGER[it] = "gui_test_retention_body" } + LEDGER["RET-14"] = "n/a: a result is never over a display limit here (the preview is a thumbnail) and every file already lives in the owned results folder" + [1, 2, 3, 4, 6, 7].each { LEDGER[String.format("RUN-%02d", it)] = "gui_test_identity_body" } + LEDGER["RUN-05"] = "n/a: Napari only (a layer is moved); an ImageData has no transform" + (1..8).each { LEDGER[String.format("ASY-%02d", it)] = "gui_test_async_body" } + (1..5).each { LEDGER[String.format("NUM-%02d", it)] = "gui_test_numbers_body" } + def testsDir = new File(System.getProperty("lc.test.fixtures", new File("tests/fixtures").absolutePath)).parentFile + fileIds.each { String id -> + def how = LEDGER[id] + if (how == null) expect("ledger_" + id + "_is_classified", false, "the file has a fixture this host has not classified: " + id) + else if (how.startsWith("n/a")) println "UNSUPPORTED " + id + ": " + how + else expect("ledger_" + id + "_has_its_body", new File(testsDir, how + ".groovy").isFile(), how) + } + expect("ledger_has_no_id_that_the_file_lacks", LEDGER.keySet().every { it in fileIds }, LEDGER.keySet().findAll { !(it in fileIds) }) + expect("ledger_the_file_has_the_fixture_counts_this_host_expects", fileIds.size() == LEDGER.size(), [fileIds.size(), LEDGER.size()]) + expect("the_conformance_file_is_not_read_from_this_repository", !confPath.startsWith(testsDir.parentFile.path), confPath) + + } catch (Throwable t) { t.printStackTrace(); results["EXCEPTION"] = false } + finally { println "RESULTS " + results; Thread.sleep(500); System.exit(results && results.every { it.value } ? 0 : 1) } +} diff --git a/tests/gui_test_parity_trust_body.groovy b/tests/gui_test_parity_trust_body.groovy index 47c08e0..410226d 100644 --- a/tests/gui_test_parity_trust_body.groovy +++ b/tests/gui_test_parity_trust_body.groovy @@ -19,7 +19,7 @@ def newPrefix = { String name -> chmod("755", prefix); chmod("755", bin); chmod("755", python) return [prefix, bin, python] } -def GOOD_SCHEMA = [protocol: 1, tools: [[id: "t.one-2", label: "T", inputs: [[name: "x_1", label: "X", type: "string"]], outputs: []]]] +def GOOD_SCHEMA = [protocol: 1, tools_version: "0.3.0a1", features: ["reserved_inputs_v1", "retention_prune"], tools: [[id: "t.one-2", label: "T", inputs: [[name: "x_1", label: "X", type: "string"]], outputs: []]]] // writes .json and its schema; `entryMods` and `schemaMods` are applied to the maps before they are written def mk = { String name, Closure entryMods = null, Closure schemaMods = null -> def (prefix, bin, python) = newPrefix(name) @@ -57,6 +57,12 @@ mk("trust_param_name_space") { e, p -> } { s -> s.tools[0].inputs[0].name = "a b mk("trust_tool_id_path") { e, p -> } { s -> s.tools[0].id = "../x" } mk("trust_protocol_2") { e, p -> } { s -> s.protocol = 2 } mk("trust_tool_no_outputs") { e, p -> } { s -> s.tools[0].remove("outputs") } +mk("trust_tools_old") { e, p -> } { s -> s.tools_version = "0.2.0rc1" } +mk("trust_tools_no_feature") { e, p -> } { s -> s.features = ["reserved_inputs_v1"] } +mk("trust_tools_no_fields") { e, p -> } { s -> s.remove("tools_version"); s.remove("features") } +mk("trust_tools_no_version") { e, p -> } { s -> s.remove("tools_version") } +mk("trust_tools_banana") { e, p -> } { s -> s.tools_version = "banana" } +mk("trust_tools_newer") { e, p -> } { s -> s.tools_version = "0.10.0"; s.features = ["reserved_inputs_v1", "retention_prune", "later"] } mk("trust_owner_other") { e, p -> }.with { if (canChown) assert ["chown", "tester", it[0].path].execute().waitFor() == 0 } mk("trust_schema_owner_other").with { if (canChown) assert ["chown", "tester", it[1].path].execute().waitFor() == 0 } @@ -89,6 +95,14 @@ refused("trust_param_name_space", "parameter without an identifier name") refused("trust_tool_id_path", "a tool has an unexpected structure") refused("trust_protocol_2", "schema protocol 2 is not supported (this host speaks [1])") refused("trust_tool_no_outputs", "a tool has an unexpected structure") +// the compatibility rules (LcCompat, the vectors of conformance.json): an app whose Tools lacks what this host needs is not offered, with one sentence; an unparsable version only warns +refused("trust_tools_old", "this app's labconstrictor-tools 0.2.0rc1 is older than the 0.3.0a1 this host needs: update it") +refused("trust_tools_no_feature", "this app's labconstrictor-tools lacks retention_prune: update it") +refused("trust_tools_no_fields", "this app's labconstrictor-tools is too old (it lists no features, this host needs reserved_inputs_v1, retention_prune): update it") +refused("trust_tools_no_version", "this app's labconstrictor-tools is too old (no version recorded, this host needs 0.3.0a1 or newer): update it") +expect("trust_tools_banana_is_offered_with_a_warning", loaded.contains("trust_tools_banana") && problemOf("trust_tools_banana") == null && (registry.warnings as List).any { it.startsWith("trust_tools_banana: the labconstrictor-tools version 'banana' of this app is not understood") }, [registry.warnings, problemOf("trust_tools_banana")]) +expect("trust_tools_newer_is_offered_without_a_warning", loaded.contains("trust_tools_newer") && !(registry.warnings as List).any { it.startsWith("trust_tools_newer") }, registry.warnings) +expect("trust_a_good_app_has_no_warning", !(registry.warnings as List).any { it.startsWith("trust_ok:") }, registry.warnings) if (canChown) { refused("trust_owner_other", "entry file is not owned by the current user") refused("trust_schema_owner_other", "schema file is not owned by the current user") diff --git a/tests/gui_test_retention_body.groovy b/tests/gui_test_retention_body.groovy index 717de97..511320a 100644 --- a/tests/gui_test_retention_body.groovy +++ b/tests/gui_test_retention_body.groovy @@ -237,13 +237,20 @@ def readText = { File f -> f.isFile() ? f.getText("UTF-8") : " lines.findAll { it.contains("deleteTemp(") && !it.contains("static void deleteTemp") }.collect { it.trim() } } expect("the_only_caller_of_it_passes_the_temporary_export_folder", callers.size() >= 1 && callers.every { it.contains("deleteTemp(tmp)") }, callers) - // ---- Tools too old: the run is refused with the sentence, no folder is made and nothing starts + // ---- Tools too old: such an app is not offered when the registry is read (the status says why); a run of an app that got through anyway is refused with the same sentence (the last line of + // defence: an app registered after the window opened), no folder is made and nothing starts installApp("runs_old") { Map schema -> schema.tools_version = "0.2.0rc1"; schema.features = ["reserved_inputs_v1", "retention_prune"] } installApp("runs_nofeature") { Map schema -> schema.features = ["reserved_inputs_v1"] } installApp("runs_unknown") { Map schema -> schema.remove("tools_version"); schema.remove("features") } fx { dlg.rescan() } + def listed = status(dlg) // "Found 1 app(s); skipped: runs_nofeature: ...; runs_old: ...; runs_unknown: ..." def count = [names(resultsRoot).size(), names(runsRoot).size()] for (def pair : [["runs_old", "0.2.0rc1 is older than the 0.3.0a1 this host needs: update it"], ["runs_nofeature", "lacks retention_prune: update it"], ["runs_unknown", "it lists no features"]]) { + expect("compat_" + pair[0] + "_is_not_offered_and_the_status_says_why", listed.contains("skipped:") && listed.contains(pair[0] + ": this app's labconstrictor-tools") && listed.contains(pair[1] as String) && !fx { dlg.apps.containsKey(pair[0]) }, listed) + fx { // an app that is already in the window's list (registered after the registry was read, or listed by an older version): its run is refused + dlg.apps[pair[0]] = [entry: LcJson.parse(new File(apps, pair[0] + ".json")), schema: LcJson.parse(new File(apps, pair[0] + ".schema.json"))] + dlg.appBox.items.add(pair[0]); null + } pick(dlg, pair[0], "Quick values") fx { dlg.run() } Thread.sleep(500) diff --git a/tests/run_gui_ci.sh b/tests/run_gui_ci.sh index 0d7f2f9..e48ad6f 100755 --- a/tests/run_gui_ci.sh +++ b/tests/run_gui_ci.sh @@ -20,6 +20,8 @@ declare -A needs=( [gui_test_calibration_body.groovy]="frame" [gui_test_retention_body.groovy]="runs" [gui_test_numbers_body.groovy]="runs" + [gui_test_async_body.groovy]="runs adv" + [gui_test_conformance_body.groovy]="interactions" [gui_test_identity_body.groovy]="runs frame" ) for b in area buttons channel copy fallbacks interactions region shapes widgets; do needs[gui_test_${b}_body.groovy]="interactions"; done @@ -29,7 +31,7 @@ needs[gui_test_parity_cancel_body.groovy]="LabConstrictorPlayground interactions declare -A scratch=([gui_test_parity_trust_body.groovy]=lhome_trust [gui_test_parity_form_body.groovy]=lhome_form [gui_test_parity_host_body.groovy]=lhome_host [gui_test_parity_lifecycle_body.groovy]=lhome_life [gui_test_parity_pixel_body.groovy]=lhome_pixel [gui_test_parity_support_body.groovy]=lhome_support [gui_test_retention_body.groovy]=lhome_retention [gui_test_identity_body.groovy]=lhome_identity - [gui_test_numbers_body.groovy]=lhome_numbers) + [gui_test_numbers_body.groovy]=lhome_numbers [gui_test_async_body.groovy]=lhome_async) SHARED_HOME="$LC_HOME"; export LC_SHARED_HOME="$SHARED_HOME" # bodies that are skipped (not failed) while their apps are not registered: the bridge of these two apps is not on their main branch yet optional="gui_test_body.groovy" diff --git a/tests/runs_app/runs_lc_tools.py b/tests/runs_app/runs_lc_tools.py index 9c8d68e..2a98eca 100644 --- a/tests/runs_app/runs_lc_tools.py +++ b/tests/runs_app/runs_lc_tools.py @@ -6,7 +6,7 @@ import time from typing import Annotated, Optional -from labconstrictor_tools import ApplyTo, ClearAfterRun, Image, Max, MessageOut, Name, PointsOut, Scalars, TableOut, ToolError, check_cancel, tool +from labconstrictor_tools import ApplyTo, ChoicesFrom, ClearAfterRun, Image, Max, MessageOut, Name, PointsOut, Scalars, TableOut, ToolError, check_cancel, tool WAIT_LIMIT_S = 180 @@ -81,3 +81,59 @@ def numbers( "ratio": repr(ratio), "ratio_type": type(ratio).__name__, "capped": str(capped), "maybe": None if maybe is None else str(maybe), } + + +# ---- ChoicesFrom lookups (ASY-01..08): the source tools answer at once, wait for a gate file (the dependency IS the gate path) or fail after it +@tool("Source A") +def choices_a(gate: str = "") -> Annotated[Scalars, Name("answer")]: + """The choices of tool A, named after the gate they were asked for: a1:, a2: (after the gate file exists).""" + _wait_for(gate, True) + name = os.path.basename(gate) + return {"choices": ["a1:" + name, "a2:" + name]} + + +@tool("Source A that fails") +def choices_a_fails(gate: str = "") -> Annotated[Scalars, Name("answer")]: + """After the gate file exists the source fails.""" + _wait_for(gate, True) + raise ToolError("source_broken", "the source of A is broken") + + +@tool("Source B") +def choices_b(key: str = "") -> Annotated[Scalars, Name("answer")]: + """The choices of tool B: b1, b2, b3 (at once).""" + return {"choices": ["b1", "b2", "b3"]} + + +@tool("Source C") +def choices_c(key: str = "") -> Annotated[Scalars, Name("answer")]: + """The choices of the second dropdown: c1, c2 (at once).""" + return {"choices": ["c1", "c2"]} + + +@tool("Tool A") +def tool_a(mode: Annotated[str, ChoicesFrom("choices_a", depends=["gate"])] = "", gate: str = "") -> Annotated[Scalars, Name("seen")]: + """A tool whose dropdown `mode` comes from the delayed source A (it depends on `gate`).""" + return {"mode": mode} + + +@tool("Tool A that fails") +def tool_a_fails(mode: Annotated[str, ChoicesFrom("choices_a_fails", depends=["gate"])] = "", gate: str = "") -> Annotated[Scalars, Name("seen")]: + """A tool whose dropdown comes from a source that fails after the gate.""" + return {"mode": mode} + + +@tool("Tool B") +def tool_b(mode: Annotated[str, ChoicesFrom("choices_b", depends=["key"])] = "", key: str = "k") -> Annotated[Scalars, Name("seen")]: + """A tool whose dropdown `mode` comes from source B (it depends on `key`).""" + return {"mode": mode} + + +@tool("Tool with two dropdowns") +def tool_two( + first: Annotated[str, ChoicesFrom("choices_b", depends=["key"])] = "", + second: Annotated[str, ChoicesFrom("choices_c", depends=["key"])] = "", + key: str = "k", +) -> Annotated[Scalars, Name("seen")]: + """Two dropdowns that depend on the same value.""" + return {"first": first, "second": second}