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
- [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

View file

@ -419,11 +419,12 @@ Status Indicators ~
Test cases use competitive programming terminology with color highlighting:
AC Accepted (passed) - Green
WA Wrong Answer (output mismatch) - Red
TLE Time Limit Exceeded (timeout) - Orange
MLE Memory Limit Exceeded Error (heuristic) - Orange
RTE Runtime Error (other non-zero exit code) - Purple
AC Accepted (passed)
WA Wrong Answer (output mismatch)
TLE Time Limit Exceeded (timeout)
MLE Memory Limit Exceeded Error (heuristic)
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
CpTestWA Red foreground for WA status
CpTestTLE Orange foreground for TLE status
CpTestMLE Orange foreground for MLE status
CpTestRTE Purple foreground for RTE status
CpTestPending Gray foreground for pending tests
CpTestNA Gray foreground for remaining state
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)')
end
local cap = utils.time_capability()
if cap.ok then
vim.health.ok('GNU time found: ' .. cap.path)
local time_cap = utils.time_capability()
if time_cap.ok then
vim.health.ok('GNU time found: ' .. time_cap.path)
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

View file

@ -63,67 +63,62 @@ function M.compile(language_config, substitutions)
return r
end
local function parse_and_strip_time_v(output, memory_mb)
local lines = vim.split(output or '', '\n', { plain = true })
local timing_idx
for i = #lines, 1, -1 do
if lines[i]:match('^%s*Command being timed:') then
timing_idx = i
local function parse_and_strip_time_v(output)
local s = output or ''
local last_i, from = nil, 1
while true do
local i = string.find(s, 'Command being timed:', from, true)
if not i then
break
end
last_i, from = i, i + 1
end
if not timing_idx then
while #lines > 0 and lines[#lines]:match('^%s*$') do
table.remove(lines, #lines)
end
return table.concat(lines, '\n'), 0, false
if not last_i then
return s, 0
end
local start_idx = timing_idx
local k = timing_idx - 1
while k >= 1 and lines[k]:match('^%s*Command ') do
start_idx = k
local k = last_i - 1
while k >= 1 do
local ch = s:sub(k, k)
if ch ~= ' ' and ch ~= '\t' then
break
end
k = k - 1
end
local peak_mb, mled = 0, false
for j = timing_idx, #lines do
local kb = lines[j]:match('Maximum resident set size %(kbytes%):%s*(%d+)')
local head = s:sub(1, k)
local tail = s:sub(last_i)
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
peak_mb = tonumber(kb) / 1024.0
if memory_mb and memory_mb > 0 and peak_mb > memory_mb then
mled = true
end
peak_kb = tonumber(kb) or 0
end
end
for j = #lines, start_idx, -1 do
table.remove(lines, j)
end
while #lines > 0 and lines[#lines]:match('^%s*$') do
table.remove(lines, #lines)
end
return table.concat(lines, '\n'), peak_mb, mled
local peak_mb = peak_kb / 1024.0
head = head:gsub('\n+$', '')
return head, peak_mb
end
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 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 r = vim
.system({ 'sh', '-c', sh }, {
stdin = stdin,
timeout = timeout_ms,
text = true,
})
:wait()
@ -142,17 +137,12 @@ function M.run(cmd, stdin, timeout_ms, memory_mb)
local lower = (cleaned or ''):lower()
local oom_hint = lower:find('std::bad_alloc', 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)
local near_cap = peak_mb >= (0.90 * memory_mb)
local near_cap = false
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
local mled = (peak_mb >= memory_mb) or near_cap or (oom_hint and not tled)
if tled then
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 stdin_content = (test_case.input or '') .. '\n'
local timeout_ms = (run_panel_state.constraints and run_panel_state.constraints.timeout_ms)
or 2000
local memory_mb = run_panel_state.constraints and run_panel_state.constraints.memory_mb or nil
local timeout_ms = (run_panel_state.constraints and run_panel_state.constraints.timeout_ms) or 0
local memory_mb = run_panel_state.constraints and run_panel_state.constraints.memory_mb or 0
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,
tled = r.tled or false,
mled = r.mled or false,
rss_mb = r.peak_mb,
rss_mb = r.peak_mb or 0,
}
end
@ -284,6 +283,7 @@ function M.handle_compilation_failure(output)
tc.signal = ''
tc.tled = false
tc.mled = false
tc.rss_mb = 0
end
end

View file

@ -30,19 +30,17 @@ function M.get_status_info(ran_test_case)
return { text = 'AC', highlight_group = 'CpTestAC' }
end
if ran_test_case.actual == '' then
return { text = '...', highlight_group = 'CpTestPending' }
end
if ran_test_case.tled then
return { text = 'TLE', highlight_group = 'CpTestTLE' }
elseif ran_test_case.mled then
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' }
else
elseif ran_test_case.code == 0 and not ran_test_case.ok then
return { text = 'WA', highlight_group = 'CpTestWA' }
end
return { text = 'N/A', highlight_group = 'CpTestNA' }
end
local function format_exit_code(code)
@ -356,7 +354,7 @@ function M.get_highlight_groups()
CpTestTLE = { fg = '#f59e0b' },
CpTestMLE = { fg = '#f59e0b' },
CpTestRTE = { fg = '#8b5cf6' },
CpTestPending = { fg = '#6b7280' },
CpTestNA = { fg = '#6b7280' },
CpDiffRemoved = { fg = '#ef4444', 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_path = nil
local _time_reason = nil
local _timeout_cached = false
local _timeout_path = nil
local _timeout_reason = nil
local function is_windows()
return uname and uname.sysname == 'Windows_NT'
@ -146,9 +149,14 @@ function M.check_required_runtime()
return false, 'Neovim 0.10.0+ required'
end
local cap = M.time_capability()
if not cap.ok then
return false, 'GNU time not found: ' .. (cap.reason or '')
local time = M.time_capability()
if not time.ok then
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
if vim.fn.executable('uv') ~= 1 then
@ -162,4 +170,62 @@ function M.check_required_runtime()
return true
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