From 4c7405705b493e9ee32cad8c959e197aeb13108a Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 20 Jul 2026 08:42:02 -0600 Subject: [PATCH 1/3] Add scriptExecutionTimeout site setting for server-side script timeout Replace the hardcoded 60-second Rhino sandbox timeout with a site setting on the Admin Console Site Settings page. The limit is wall-clock time measured from script context creation, so it includes time spent in database operations and nested trigger scripts fired from the script; bulk imports that fire large trigger chains can exceed 60 seconds even when the scripts themselves do little work. The default remains 60 seconds and 0 disables the timeout, following the convention of the adjacent readOnlyHttpRequestTimeout setting. The value is resolved once per Rhino context since observeInstructionCount runs every 30k instructions, and it is also settable as a startup property via SiteSettings.scriptExecutionTimeout. Claude-Session: https://claude.ai/code/session_01CvVMhiSzPnaJtdn1w2Mef4 --- api/src/org/labkey/api/settings/AppProps.java | 5 +++++ .../org/labkey/api/settings/AppPropsImpl.java | 6 ++++++ .../api/settings/SiteSettingsProperties.java | 8 ++++++++ .../labkey/api/settings/WriteableAppProps.java | 7 +++++++ .../org/labkey/core/admin/AdminController.java | 16 ++++++++++++++++ core/src/org/labkey/core/admin/customizeSite.jsp | 5 +++++ .../src/org/labkey/core/script/RhinoService.java | 9 ++++++--- 7 files changed, 53 insertions(+), 3 deletions(-) diff --git a/api/src/org/labkey/api/settings/AppProps.java b/api/src/org/labkey/api/settings/AppProps.java index 3ae76372530..27480cc7b45 100644 --- a/api/src/org/labkey/api/settings/AppProps.java +++ b/api/src/org/labkey/api/settings/AppProps.java @@ -173,6 +173,11 @@ static WriteableAppProps getWriteableInstance() /** Timeout in seconds for read-only HTTP requests, after which resources like DB connections and spawned processes will be killed. Set to 0 to disable. */ int getReadOnlyHttpRequestTimeout(); + int DEFAULT_SCRIPT_EXECUTION_TIMEOUT = 60; + + /** Timeout in seconds for server-side JavaScript (e.g. trigger scripts), measured in wall-clock time including database and other Java operations invoked by the script. Set to 0 to disable. */ + int getScriptExecutionTimeout(); + int getMaxBLOBSize(); ExceptionReportingLevel getExceptionReportingLevel(); diff --git a/api/src/org/labkey/api/settings/AppPropsImpl.java b/api/src/org/labkey/api/settings/AppPropsImpl.java index 60b22b957fe..ac90bf67310 100644 --- a/api/src/org/labkey/api/settings/AppPropsImpl.java +++ b/api/src/org/labkey/api/settings/AppPropsImpl.java @@ -321,6 +321,12 @@ public int getReadOnlyHttpRequestTimeout() return lookupIntValue(readOnlyHttpRequestTimeout, 0); } + @Override + public int getScriptExecutionTimeout() + { + return lookupIntValue(scriptExecutionTimeout, DEFAULT_SCRIPT_EXECUTION_TIMEOUT); + } + @Override public int getMaxBLOBSize() { diff --git a/api/src/org/labkey/api/settings/SiteSettingsProperties.java b/api/src/org/labkey/api/settings/SiteSettingsProperties.java index 961ecb54868..8cbc673f4aa 100644 --- a/api/src/org/labkey/api/settings/SiteSettingsProperties.java +++ b/api/src/org/labkey/api/settings/SiteSettingsProperties.java @@ -90,6 +90,14 @@ public void setValue(WriteableAppProps writeable, String value) writeable.setReadOnlyHttpRequestTimeout(Integer.parseInt(value)); } }, + scriptExecutionTimeout("Timeout in seconds for server-side JavaScript such as trigger scripts. Measured in wall-clock time, including database and other Java operations invoked by the script. Set to 0 to disable.") + { + @Override + public void setValue(WriteableAppProps writeable, String value) + { + writeable.setScriptExecutionTimeout(Integer.parseInt(value)); + } + }, maxBLOBSize("Maximum file size, in bytes, to allow in database BLOBs") { @Override diff --git a/api/src/org/labkey/api/settings/WriteableAppProps.java b/api/src/org/labkey/api/settings/WriteableAppProps.java index e3691f745b0..92df26b757b 100644 --- a/api/src/org/labkey/api/settings/WriteableAppProps.java +++ b/api/src/org/labkey/api/settings/WriteableAppProps.java @@ -94,6 +94,13 @@ public void setReadOnlyHttpRequestTimeout(int timeout) storeIntValue(readOnlyHttpRequestTimeout, timeout); } + public void setScriptExecutionTimeout(int timeout) + { + if (timeout < 0) + throw new IllegalArgumentException("scriptExecutionTimeout must be >= 0"); + storeIntValue(scriptExecutionTimeout, timeout); + } + public void setMaxBLOBSize(int maxSize) { if (maxSize < 0) diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index 64d0cdcc26e..9dbab9a71a3 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -1381,6 +1381,10 @@ public void validateCommand(SiteSettingsForm form, Errors errors) { errors.reject(ERROR_MSG, "Memory logging frequency must be non-negative"); } + if (form.getScriptExecutionTimeout() < 0) + { + errors.reject(ERROR_MSG, "Script execution timeout must be non-negative"); + } } @Override @@ -1415,6 +1419,7 @@ public boolean handlePost(SiteSettingsForm form, BindException errors) throws Ex props.setSSLPort(form.getSslPort()); props.setMemoryUsageDumpInterval(form.getMemoryUsageDumpInterval()); props.setReadOnlyHttpRequestTimeout(form.getReadOnlyHttpRequestTimeout()); + props.setScriptExecutionTimeout(form.getScriptExecutionTimeout()); props.setMaxBLOBSize(form.getMaxBLOBSize()); props.setSelfReportExceptions(form.isSelfReportExceptions()); @@ -2384,6 +2389,7 @@ public static class SiteSettingsForm private int _sslPort; private int _memoryUsageDumpInterval; private int _readOnlyHttpRequestTimeout; + private int _scriptExecutionTimeout; private int _maxBLOBSize; private String _exceptionReportingLevel; private String _usageReportingLevel; @@ -2524,6 +2530,16 @@ public int getReadOnlyHttpRequestTimeout() return _readOnlyHttpRequestTimeout; } + public int getScriptExecutionTimeout() + { + return _scriptExecutionTimeout; + } + + public void setScriptExecutionTimeout(int timeout) + { + _scriptExecutionTimeout = timeout; + } + public void setReadOnlyHttpRequestTimeout(int timeout) { _readOnlyHttpRequestTimeout = timeout; diff --git a/core/src/org/labkey/core/admin/customizeSite.jsp b/core/src/org/labkey/core/admin/customizeSite.jsp index 8bc659eb5ac..1de822de1e6 100644 --- a/core/src/org/labkey/core/admin/customizeSite.jsp +++ b/core/src/org/labkey/core/admin/customizeSite.jsp @@ -312,6 +312,11 @@ Click the Save button at any time to accept the current settings and continue. + + + + diff --git a/core/src/org/labkey/core/script/RhinoService.java b/core/src/org/labkey/core/script/RhinoService.java index 497a6d99b1b..6ec68f8fbf4 100644 --- a/core/src/org/labkey/core/script/RhinoService.java +++ b/core/src/org/labkey/core/script/RhinoService.java @@ -40,6 +40,7 @@ import org.labkey.api.resource.Resource; import org.labkey.api.script.ScriptReference; import org.labkey.api.script.ScriptService; +import org.labkey.api.settings.AppProps; import org.labkey.api.test.TestWhen; import org.labkey.api.util.HeartBeat; import org.labkey.api.util.JunitUtil; @@ -1006,9 +1007,8 @@ protected void observeInstructionCount(Context cx, int instructionCount) { SandboxContext ctx = (SandboxContext)cx; long currentTime = HeartBeat.currentTimeMillis(); - final int timeout = 60; - if (currentTime - ctx.startTime > timeout*1000) - Context.reportError("Script execution exceeded " + timeout + " seconds."); + if (ctx.timeoutSeconds > 0 && currentTime - ctx.startTime > ctx.timeoutSeconds * 1000L) + Context.reportError("Script execution exceeded " + ctx.timeoutSeconds + " seconds."); } @Override @@ -1044,12 +1044,15 @@ public boolean visibleToScripts(String fullClassName) private static class SandboxContext extends Context { private final long startTime; + // resolved once per context; observeInstructionCount runs far too often for a property lookup + private final int timeoutSeconds; private SandboxContext(SandboxContextFactory factory) { super(factory); setLanguageVersion(Context.VERSION_1_8); startTime = HeartBeat.currentTimeMillis(); + timeoutSeconds = AppProps.getInstance().getScriptExecutionTimeout(); } } From caefa97d96d4b506ec641e0efb19b1096346bab9 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Mon, 20 Jul 2026 10:32:45 -0600 Subject: [PATCH 2/3] Bind scriptExecutionTimeout as nullable Integer and add timeout test Follow-up to the scriptExecutionTimeout site setting: the SiteSettingsForm field is now a nullable Integer so a POST that omits the parameter leaves the stored setting unchanged instead of binding 0 and silently disabling the script watchdog. Saving is skipped when the value is absent, and the negative-value validation is null-guarded. Also adds RhinoService.TestCase.timeoutTest (BVT), which verifies that a 1-second timeout aborts a runaway script with the expected error message and that a value of 0 disables the watchdog entirely. Claude-Session: https://claude.ai/code/session_01LEjXWPFro12ZV6YcwtFMvi --- .../labkey/core/admin/AdminController.java | 13 +++--- .../org/labkey/core/script/RhinoService.java | 41 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index 9dbab9a71a3..88d02a93683 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -1381,7 +1381,7 @@ public void validateCommand(SiteSettingsForm form, Errors errors) { errors.reject(ERROR_MSG, "Memory logging frequency must be non-negative"); } - if (form.getScriptExecutionTimeout() < 0) + if (form.getScriptExecutionTimeout() != null && form.getScriptExecutionTimeout() < 0) { errors.reject(ERROR_MSG, "Script execution timeout must be non-negative"); } @@ -1419,7 +1419,8 @@ public boolean handlePost(SiteSettingsForm form, BindException errors) throws Ex props.setSSLPort(form.getSslPort()); props.setMemoryUsageDumpInterval(form.getMemoryUsageDumpInterval()); props.setReadOnlyHttpRequestTimeout(form.getReadOnlyHttpRequestTimeout()); - props.setScriptExecutionTimeout(form.getScriptExecutionTimeout()); + if (form.getScriptExecutionTimeout() != null) + props.setScriptExecutionTimeout(form.getScriptExecutionTimeout()); props.setMaxBLOBSize(form.getMaxBLOBSize()); props.setSelfReportExceptions(form.isSelfReportExceptions()); @@ -2389,7 +2390,7 @@ public static class SiteSettingsForm private int _sslPort; private int _memoryUsageDumpInterval; private int _readOnlyHttpRequestTimeout; - private int _scriptExecutionTimeout; + private Integer _scriptExecutionTimeout; private int _maxBLOBSize; private String _exceptionReportingLevel; private String _usageReportingLevel; @@ -2530,12 +2531,14 @@ public int getReadOnlyHttpRequestTimeout() return _readOnlyHttpRequestTimeout; } - public int getScriptExecutionTimeout() + /** Null when the request omits the parameter; leave the stored setting unchanged in that case. */ + @Nullable + public Integer getScriptExecutionTimeout() { return _scriptExecutionTimeout; } - public void setScriptExecutionTimeout(int timeout) + public void setScriptExecutionTimeout(@Nullable Integer timeout) { _scriptExecutionTimeout = timeout; } diff --git a/core/src/org/labkey/core/script/RhinoService.java b/core/src/org/labkey/core/script/RhinoService.java index 6ec68f8fbf4..71d41196269 100644 --- a/core/src/org/labkey/core/script/RhinoService.java +++ b/core/src/org/labkey/core/script/RhinoService.java @@ -40,12 +40,15 @@ import org.labkey.api.resource.Resource; import org.labkey.api.script.ScriptReference; import org.labkey.api.script.ScriptService; +import org.labkey.api.security.User; import org.labkey.api.settings.AppProps; +import org.labkey.api.settings.WriteableAppProps; import org.labkey.api.test.TestWhen; import org.labkey.api.util.HeartBeat; import org.labkey.api.util.JunitUtil; import org.labkey.api.util.MemTracker; import org.labkey.api.util.Path; +import org.labkey.api.util.TestContext; import org.labkey.api.util.UnexpectedException; import org.labkey.api.view.HttpView; import org.mozilla.javascript.ClassShutter; @@ -200,6 +203,44 @@ public void reportTest() throws Exception test("reportTest"); } + @Test + public void timeoutTest() throws Exception + { + int original = AppProps.getInstance().getScriptExecutionTimeout(); + User user = TestContext.get().getUser(); + + try + { + // Busy-loop bounded at 15 seconds of wall-clock time; the 1-second watchdog should abort it long before that. HeartBeat ticks once per second, so the abort lands after 2-3 real seconds. + setScriptExecutionTimeout(1, user); + try + { + RhinoService.RHINO_FACTORY.getScriptEngine().eval("var start = new Date().getTime(); while (new Date().getTime() - start < 15000) {}"); + fail("Expected script to be terminated by the execution timeout"); + } + catch (Exception e) + { + assertTrue("Unexpected script error: " + e.getMessage(), e.getMessage().contains("Script execution exceeded 1 seconds")); + } + + // 0 disables the watchdog: a loop that runs well past the 1-second timeout above should complete normally + setScriptExecutionTimeout(0, user); + Object result = RhinoService.RHINO_FACTORY.getScriptEngine().eval("var start = new Date().getTime(); while (new Date().getTime() - start < 2500) {} 'completed';"); + assertEquals("completed", result); + } + finally + { + setScriptExecutionTimeout(original, user); + } + } + + private void setScriptExecutionTimeout(int seconds, User user) + { + WriteableAppProps props = AppProps.getWriteableInstance(); + props.setScriptExecutionTimeout(seconds); + props.save(user); + } + private void test(String scriptName) throws ScriptException, NoSuchMethodException { Path js = Path.parse(ScriptService.SCRIPTS_DIR + "/validationTest/" + scriptName + ".js"); From b8282e02e9cbadfc1180c852dd39919781250bb8 Mon Sep 17 00:00:00 2001 From: Marty Pradere Date: Tue, 21 Jul 2026 17:16:15 -0600 Subject: [PATCH 3/3] Reject blank script execution timeout instead of silently ignoring it --- core/src/org/labkey/core/admin/AdminController.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/core/src/org/labkey/core/admin/AdminController.java b/core/src/org/labkey/core/admin/AdminController.java index 88d02a93683..97175dc35ba 100644 --- a/core/src/org/labkey/core/admin/AdminController.java +++ b/core/src/org/labkey/core/admin/AdminController.java @@ -1381,7 +1381,11 @@ public void validateCommand(SiteSettingsForm form, Errors errors) { errors.reject(ERROR_MSG, "Memory logging frequency must be non-negative"); } - if (form.getScriptExecutionTimeout() != null && form.getScriptExecutionTimeout() < 0) + if (form.getScriptExecutionTimeout() == null) + { + errors.reject(ERROR_MSG, "Script execution timeout is required; set to 0 to disable the timeout"); + } + else if (form.getScriptExecutionTimeout() < 0) { errors.reject(ERROR_MSG, "Script execution timeout must be non-negative"); } @@ -1419,8 +1423,7 @@ public boolean handlePost(SiteSettingsForm form, BindException errors) throws Ex props.setSSLPort(form.getSslPort()); props.setMemoryUsageDumpInterval(form.getMemoryUsageDumpInterval()); props.setReadOnlyHttpRequestTimeout(form.getReadOnlyHttpRequestTimeout()); - if (form.getScriptExecutionTimeout() != null) - props.setScriptExecutionTimeout(form.getScriptExecutionTimeout()); + props.setScriptExecutionTimeout(form.getScriptExecutionTimeout()); props.setMaxBLOBSize(form.getMaxBLOBSize()); props.setSelfReportExceptions(form.isSelfReportExceptions()); @@ -2531,7 +2534,7 @@ public int getReadOnlyHttpRequestTimeout() return _readOnlyHttpRequestTimeout; } - /** Null when the request omits the parameter; leave the stored setting unchanged in that case. */ + /** Null when the request omits or blanks the parameter; rejected in validateCommand so an omitted value can never bind to 0 and silently disable the timeout. */ @Nullable public Integer getScriptExecutionTimeout() {