Merge pull request #138 from barrett-ruth/feat/timeout

Fix Timeout with GNU Timeout
This commit is contained in:
Barrett Ruth 2025-10-03 15:35:25 +02:00 committed by GitHub
commit 7be37ad96e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 137 additions and 73 deletions

View file

@ -19,6 +19,7 @@ https://github.com/user-attachments/assets/50b19481-8e6d-47b4-bebc-15e16c61a9c9
- [uv](https://docs.astral.sh/uv/) for problem scraping - [uv](https://docs.astral.sh/uv/) for problem scraping
- [LuaSnip](https://github.com/L3MON4D3/LuaSnip) for templates - [LuaSnip](https://github.com/L3MON4D3/LuaSnip) for templates
- GNU [time](https://www.gnu.org/software/time/) and [timeout](https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html)
## Quick Start ## Quick Start

View file

@ -419,11 +419,12 @@ Status Indicators ~
Test cases use competitive programming terminology with color highlighting: Test cases use competitive programming terminology with color highlighting:
AC Accepted (passed) - Green AC Accepted (passed)
WA Wrong Answer (output mismatch) - Red WA Wrong Answer (output mismatch)
TLE Time Limit Exceeded (timeout) - Orange TLE Time Limit Exceeded (timeout)
MLE Memory Limit Exceeded Error (heuristic) - Orange MLE Memory Limit Exceeded Error (heuristic)
RTE Runtime Error (other non-zero exit code) - Purple RTE Runtime Error (other non-zero exit code)
NA Any other state (undecipherable, error, running)
< <
============================================================================== ==============================================================================
@ -442,8 +443,9 @@ Test cases use competitive programming terminology with color highlighting:
CpTestAC Green foreground for AC status CpTestAC Green foreground for AC status
CpTestWA Red foreground for WA status CpTestWA Red foreground for WA status
CpTestTLE Orange foreground for TLE status CpTestTLE Orange foreground for TLE status
CpTestMLE Orange foreground for MLE status
CpTestRTE Purple foreground for RTE status CpTestRTE Purple foreground for RTE status
CpTestPending Gray foreground for pending tests CpTestNA Gray foreground for remaining state
ANSI Color Groups ~ ANSI Color Groups ~

View file

@ -34,11 +34,18 @@ local function check_required()
vim.health.info('Python virtual environment not set up (created on first scrape)') vim.health.info('Python virtual environment not set up (created on first scrape)')
end end
local cap = utils.time_capability() local time_cap = utils.time_capability()
if cap.ok then if time_cap.ok then
vim.health.ok('GNU time found: ' .. cap.path) vim.health.ok('GNU time found: ' .. time_cap.path)
else else
vim.health.error('GNU time not found: ' .. (cap.reason or '')) vim.health.error('GNU time not found: ' .. (time_cap.reason or ''))
end
local timeout_cap = utils.time_capability()
if timeout_cap.ok then
vim.health.ok('GNU timeout found: ' .. timeout_cap.path)
else
vim.health.error('GNU timeout not found: ' .. (timeout_cap.reason or ''))
end end
end end

View file

@ -63,67 +63,62 @@ function M.compile(language_config, substitutions)
return r return r
end end
local function parse_and_strip_time_v(output, memory_mb) local function parse_and_strip_time_v(output)
local lines = vim.split(output or '', '\n', { plain = true }) local s = output or ''
local last_i, from = nil, 1
local timing_idx while true do
for i = #lines, 1, -1 do local i = string.find(s, 'Command being timed:', from, true)
if lines[i]:match('^%s*Command being timed:') then if not i then
timing_idx = i
break break
end end
last_i, from = i, i + 1
end end
if not timing_idx then if not last_i then
while #lines > 0 and lines[#lines]:match('^%s*$') do return s, 0
table.remove(lines, #lines)
end
return table.concat(lines, '\n'), 0, false
end end
local start_idx = timing_idx local k = last_i - 1
local k = timing_idx - 1 while k >= 1 do
while k >= 1 and lines[k]:match('^%s*Command ') do local ch = s:sub(k, k)
start_idx = k if ch ~= ' ' and ch ~= '\t' then
break
end
k = k - 1 k = k - 1
end end
local peak_mb, mled = 0, false local head = s:sub(1, k)
for j = timing_idx, #lines do local tail = s:sub(last_i)
local kb = lines[j]:match('Maximum resident set size %(kbytes%):%s*(%d+)')
local peak_kb = 0.0
for line in tail:gmatch('[^\n]+') do
local kb = line:match('Maximum resident set size %(kbytes%):%s*(%d+)')
if kb then if kb then
peak_mb = tonumber(kb) / 1024.0 peak_kb = tonumber(kb) or 0
if memory_mb and memory_mb > 0 and peak_mb > memory_mb then
mled = true
end
end end
end end
for j = #lines, start_idx, -1 do local peak_mb = peak_kb / 1024.0
table.remove(lines, j) head = head:gsub('\n+$', '')
end return head, peak_mb
while #lines > 0 and lines[#lines]:match('^%s*$') do
table.remove(lines, #lines)
end
return table.concat(lines, '\n'), peak_mb, mled
end end
function M.run(cmd, stdin, timeout_ms, memory_mb) function M.run(cmd, stdin, timeout_ms, memory_mb)
local prog = table.concat(cmd, ' ')
local pre = {}
if memory_mb and memory_mb > 0 then
table.insert(pre, ('ulimit -v %d'):format(memory_mb * 1024))
end
local prefix = (#pre > 0) and (table.concat(pre, '; ') .. '; ') or ''
local time_bin = utils.time_path() local time_bin = utils.time_path()
local sh = prefix .. ('%s -v sh -c %q 2>&1'):format(time_bin, prog) local timeout_bin = utils.timeout_path()
local prog = table.concat(cmd, ' ')
local pre = {
('ulimit -v %d'):format(memory_mb * 1024),
}
local prefix = table.concat(pre, '; ') .. '; '
local sec = math.ceil(timeout_ms / 1000)
local timeout_prefix = ('%s -k 1s %ds '):format(timeout_bin, sec)
local sh = prefix .. timeout_prefix .. ('%s -v sh -c %q 2>&1'):format(time_bin, prog)
local t0 = vim.uv.hrtime() local t0 = vim.uv.hrtime()
local r = vim local r = vim
.system({ 'sh', '-c', sh }, { .system({ 'sh', '-c', sh }, {
stdin = stdin, stdin = stdin,
timeout = timeout_ms,
text = true, text = true,
}) })
:wait() :wait()
@ -142,17 +137,12 @@ function M.run(cmd, stdin, timeout_ms, memory_mb)
local lower = (cleaned or ''):lower() local lower = (cleaned or ''):lower()
local oom_hint = lower:find('std::bad_alloc', 1, true) local oom_hint = lower:find('std::bad_alloc', 1, true)
or lower:find('cannot allocate memory', 1, true) or lower:find('cannot allocate memory', 1, true)
or lower:find('out of memory', 1, true)
or lower:find('oom', 1, true)
or lower:find('enomem', 1, true) or lower:find('enomem', 1, true)
local near_cap = peak_mb >= (0.90 * memory_mb)
local near_cap = false local mled = (peak_mb >= memory_mb) or near_cap or (oom_hint and not tled)
if memory_mb and memory_mb > 0 then
near_cap = (peak_mb >= (0.90 * memory_mb))
end
local mled = false
if peak_mb >= memory_mb or near_cap or oom_hint then
mled = true
end
if tled then if tled then
logger.log(('Execution timed out in %.1fms.'):format(dt)) logger.log(('Execution timed out in %.1fms.'):format(dt))

View file

@ -122,9 +122,8 @@ local function run_single_test_case(contest_config, cp_config, test_case)
local cmd = build_command(language_config, substitutions) local cmd = build_command(language_config, substitutions)
local stdin_content = (test_case.input or '') .. '\n' local stdin_content = (test_case.input or '') .. '\n'
local timeout_ms = (run_panel_state.constraints and run_panel_state.constraints.timeout_ms) local timeout_ms = (run_panel_state.constraints and run_panel_state.constraints.timeout_ms) or 0
or 2000 local memory_mb = run_panel_state.constraints and run_panel_state.constraints.memory_mb or 0
local memory_mb = run_panel_state.constraints and run_panel_state.constraints.memory_mb or nil
local r = exec.run(cmd, stdin_content, timeout_ms, memory_mb) local r = exec.run(cmd, stdin_content, timeout_ms, memory_mb)
@ -184,7 +183,7 @@ local function run_single_test_case(contest_config, cp_config, test_case)
signal = signal, signal = signal,
tled = r.tled or false, tled = r.tled or false,
mled = r.mled or false, mled = r.mled or false,
rss_mb = r.peak_mb, rss_mb = r.peak_mb or 0,
} }
end end
@ -284,6 +283,7 @@ function M.handle_compilation_failure(output)
tc.signal = '' tc.signal = ''
tc.tled = false tc.tled = false
tc.mled = false tc.mled = false
tc.rss_mb = 0
end end
end end

View file

@ -30,19 +30,17 @@ function M.get_status_info(ran_test_case)
return { text = 'AC', highlight_group = 'CpTestAC' } return { text = 'AC', highlight_group = 'CpTestAC' }
end end
if ran_test_case.actual == '' then
return { text = '...', highlight_group = 'CpTestPending' }
end
if ran_test_case.tled then if ran_test_case.tled then
return { text = 'TLE', highlight_group = 'CpTestTLE' } return { text = 'TLE', highlight_group = 'CpTestTLE' }
elseif ran_test_case.mled then elseif ran_test_case.mled then
return { text = 'MLE', highlight_group = 'CpTestMLE' } return { text = 'MLE', highlight_group = 'CpTestMLE' }
elseif ran_test_case.code and ran_test_case.code >= 128 then elseif ran_test_case.code > 0 and ran_test_case.code >= 128 then
return { text = 'RTE', highlight_group = 'CpTestRTE' } return { text = 'RTE', highlight_group = 'CpTestRTE' }
else elseif ran_test_case.code == 0 and not ran_test_case.ok then
return { text = 'WA', highlight_group = 'CpTestWA' } return { text = 'WA', highlight_group = 'CpTestWA' }
end end
return { text = 'N/A', highlight_group = 'CpTestNA' }
end end
local function format_exit_code(code) local function format_exit_code(code)
@ -356,7 +354,7 @@ function M.get_highlight_groups()
CpTestTLE = { fg = '#f59e0b' }, CpTestTLE = { fg = '#f59e0b' },
CpTestMLE = { fg = '#f59e0b' }, CpTestMLE = { fg = '#f59e0b' },
CpTestRTE = { fg = '#8b5cf6' }, CpTestRTE = { fg = '#8b5cf6' },
CpTestPending = { fg = '#6b7280' }, CpTestNA = { fg = '#6b7280' },
CpDiffRemoved = { fg = '#ef4444', bg = '#1f1f1f' }, CpDiffRemoved = { fg = '#ef4444', bg = '#1f1f1f' },
CpDiffAdded = { fg = '#10b981', bg = '#1f1f1f' }, CpDiffAdded = { fg = '#10b981', bg = '#1f1f1f' },
} }

View file

@ -7,6 +7,9 @@ local uname = vim.loop.os_uname()
local _time_cached = false local _time_cached = false
local _time_path = nil local _time_path = nil
local _time_reason = nil local _time_reason = nil
local _timeout_cached = false
local _timeout_path = nil
local _timeout_reason = nil
local function is_windows() local function is_windows()
return uname and uname.sysname == 'Windows_NT' return uname and uname.sysname == 'Windows_NT'
@ -146,9 +149,14 @@ function M.check_required_runtime()
return false, 'Neovim 0.10.0+ required' return false, 'Neovim 0.10.0+ required'
end end
local cap = M.time_capability() local time = M.time_capability()
if not cap.ok then if not time.ok then
return false, 'GNU time not found: ' .. (cap.reason or '') return false, 'GNU time not found: ' .. (time.reason or '')
end
local timeout = M.timeout_capability()
if not timeout.ok then
return false, 'GNU timeout not found: ' .. (timeout.reason or '')
end end
if vim.fn.executable('uv') ~= 1 then if vim.fn.executable('uv') ~= 1 then
@ -162,4 +170,62 @@ function M.check_required_runtime()
return true return true
end end
local function check_timeout_is_gnu_timeout(bin)
if vim.fn.executable(bin) ~= 1 then
return false
end
local r = vim.system({ bin, '--version' }, { text = true }):wait()
if r and r.code == 0 and r.stdout then
local s = r.stdout:lower()
if s:find('gnu coreutils', 1, true) or s:find('timeout %(gnu coreutils%)', 1, true) then
return true
end
end
return false
end
local function find_gnu_timeout()
if _timeout_cached then
return _timeout_path, _timeout_reason
end
if is_windows() then
_timeout_cached = true
_timeout_path = nil
_timeout_reason = 'unsupported on Windows'
return _timeout_path, _timeout_reason
end
local candidates
if uname and uname.sysname == 'Darwin' then
candidates = { 'gtimeout', '/opt/homebrew/bin/gtimeout', '/usr/local/bin/gtimeout' }
else
candidates = { '/usr/bin/timeout', 'timeout' }
end
for _, bin in ipairs(candidates) do
if check_timeout_is_gnu_timeout(bin) then
_timeout_cached = true
_timeout_path = bin
_timeout_reason = nil
return _timeout_path, _timeout_reason
end
end
_timeout_cached = true
_timeout_path = nil
_timeout_reason = 'GNU timeout not found (install `coreutils`; macOS: `brew install coreutils`)'
return _timeout_path, _timeout_reason
end
function M.timeout_path()
local path = find_gnu_timeout()
return path
end
function M.timeout_capability()
local path, reason = find_gnu_timeout()
return { ok = path ~= nil, path = path, reason = reason }
end
return M return M