`call_with_time_limit/2` Not Enforcing Time Limit as Expected

Hello,

I’m encountering an issue with the call_with_time_limit/2 predicate in SWI-Prolog, where the time limit doesn’t seem to be enforced as expected. I’ve created two test cases that illustrate the problem: one using sleep/1 and the other using a computationally intensive operation.

System Details:

  • SWI-Prolog Version: 9.3.10-7-gfbc8aa081 (64 bits, threaded)
  • Operating System: Debian 12 on WSL (Windows Subsystem for Linux)

Test Case 1: Using sleep/1

Here’s a simple test case that uses sleep/1:

Prolog Code:

:- use_module(library(plunit)).

% Test case to ensure that call_with_time_limit/2 interrupts the sleep operation.
:- begin_tests(time_limit_test).

test(time_limit_exceeded, [throws(error(time_limit_exceeded, _))]) :-
    time(call_with_time_limit(1, forall(between(1,80,_), sleep(0.1)))).

:- end_tests(time_limit_test).

% Run the test
:- run_tests.

Expected Behavior:

The goal should be interrupted after 1 second, raising a time_limit_exceeded exception.

Actual Behavior:

?- time(call_with_time_limit(1,forall(between(1,80,_),sleep(0.1)))).
% 169 inferences, 0.002 CPU in 8.009 seconds (0% CPU, 70165 Lips)
true.

The goal continues to run well beyond the specified time limit without raising an exception, as demonstrated by the output above where the goal took over 8 seconds to complete.

Test Case 2: Using a Computationally Intensive Operation

I also created a test case that involves summing a large range of numbers, which should take enough time to trigger the time limit.

Bash Script to Create and Run the Test:

#!/bin/bash

# Create the Prolog test file
cat << 'EOF' > time_limit_test.pl
:- use_module(library(plunit)).

% A simple predicate that performs a large number of computations.
% It sums all integers from 1 to N.
sum_to(N, Sum) :-
    sum_to(N, 0, Sum).

sum_to(0, Acc, Acc).
sum_to(N, Acc, Sum) :-
    N > 0,
    NewAcc is Acc + N,
    NewN is N - 1,
    sum_to(NewN, NewAcc, Sum).

% Test predicate to simulate a long-running operation without using sleep.
test_long_running :-
    Max = 10^8,  % This large number ensures the computation takes a significant amount of time.
    sum_to(Max, _).

% Test case to ensure that call_with_time_limit/2 interrupts the computation.
:- begin_tests(time_limit_test).

test(time_limit_exceeded, [throws(error(time_limit_exceeded, _))]) :-
    call_with_time_limit(1, test_long_running).  % Set timeout to 1 second

:- end_tests(time_limit_test).

% Run the test
:- run_tests.
EOF

# Run the Prolog test
swipl -q -s time_limit_test.pl -t halt

Expected Behavior:

The computation should be interrupted after 1 second, raising a time_limit_exceeded exception.

Actual Behavior:

Similar to the sleep/1 example, the time limit is not enforced, and the computation continues beyond the specified limit.

Additional Notes:

These test cases demonstrate that call_with_time_limit/2 is not enforcing the time limits as expected in different scenarios. I would appreciate any insights or suggestions on how to address this issue. Is this a known problem, or could there be something specific to my environment causing this behavior?

Thank you for your attention to this matter!

Best regards,
Douglas

Works fine, but the problem is that you run the tests from a directive. Loading files is guarded against signals to avoid it being interrupted, leaving the system in some unpredictable state. Consider you run a call_with_time_limit/2 that traps the autoloader. Now the loading could be aborted at any point in time while loading the file, but we have no safe mechanism to cleanly abort loading a file. Reclaiming the clauses is easy, but side effects as a result of directives is not easily undone.

You can use initialization/2 as below. The (single) main initialization is executed after loading all files has completed.

:- initialization(run_tests, main).

Note that not all blocking OS calls can be interrupted (depending on the OS). Foreign code cannot be interrupted at all, unless it calls PL_handle_signals() frequently and return with FAIL in case this returns < 0.

Note that call_with_time_limit/2 uses thread_signal/2 to raise a time_limit_exceeded exception. As a result, you can protect some code using sig_atomic/1 from being interrupted.

Note that this does not throw error(_,_), but the plain atom. That allows for catching errors and pass timeout exceptions easily. Same holds for abort/0.