You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Judge sandbox: cap resident memory with cgroups #86
Duas pontas ficaram em aberto quando o sandbox bwrap entrou:
1. Memória residente não tem teto real
O sandbox aplica ulimit -t (CPU), -f (tamanho de arquivo) e -u (processos), mas nãoulimit -v: JVM, Go e Rust reservam faixas grandes de endereçamento virtual no startup e morreriam sob um teto de address space independentemente da memória que realmente tocam.
O resultado é que memória residente hoje depende só do flag {memory} por linguagem (-Xmx e similares) — o que não existe para C, C++, Rust, Go, Pascal. Um malloc em loop numa submissão em C não tem limite nenhum além da RAM da máquina.
O caminho certo é cgroup v2 por run (memory.max), não rlimit.
safeexec DENTRO do bwrap -> 137 "ERROR Operation not permitted"
bwrap SEM safeexec -> 0 "8"
safeexec SEM bwrap -> 16 "unable to exec python3"
A primeira linha tem explicação: o safeexec é setuid-root (chmod 4555 no Dockerfile.judge) e o bubblewrap monta tudo nosuid com no_new_privs por design, então a escalada falha com EPERM. Por isso a #77 tornou os dois mutuamente exclusivos.
A terceira linha é o problema: o safeexec falha em achar o interpretador mesmo sem sandbox nenhum. Isso é anterior à #77 e significa que, em qualquer implantação Docker que aponte AUTOJUDGE_SAFEEXEC_PATH para o binário real (o .env.docker aponta), o julgamento estava quebrado antes do sandbox existir.
Como o sandbox agora entrega isolamento e limites, o safeexec não tem mais função. Proposta: remover tools/safeexec.c, a compilação no Dockerfile.judge, wrapWithSafeExec() e a config safeexec_path — em vez de consertar um binário que nada mais usa.
Escopo sugerido
cgroup v2 por run com memory.max, e mapear o OOM kill para MLE em vez de RE genérico
remover o safeexec inteiro
estender tests/Integration/JudgeSandboxConfinementTest.php com um caso de estouro de memória, já que ele hoje afere tempo/disco/processos mas não memória
A #96 remove o safeexec e o resto do modelo pré-sandbox. A metade de cgroups fica aberta, e aqui está o que eu medi para quem for pegar, em vez de deixar a pessoa começar do zero.
O que dá e o que não dá hoje
Na imagem do juiz, com --privileged:
$ stat -fc %T /sys/fs/cgroup # cgroup2fs
$ cat /sys/fs/cgroup/cgroup.controllers
cpuset cpu io memory hugetlb pids rdma
O controlador memoryestá disponível. Mas:
$ id
uid=1000(judge) gid=1000(judge)
$ ls -ld /sys/fs/cgroup
drwxr-xr-x 2 root root
$ mkdir /sys/fs/cgroup/probe
mkdir: Permission denied
O container roda como judge (o Dockerfile.judge tem USER judge) e a raiz do cgroup é do root. Tentando delegar como root no entrypoint:
Isso falha pela regra "no internal processes" do cgroup v2: não dá para habilitar controlador num cgroup que já tem processos membros. O caminho documentado é mover os processos existentes para um cgroup folha (/sys/fs/cgroup/init) antes de habilitar o controlador na raiz — e só então criar e delegar /sys/fs/cgroup/judge para o uid do juiz.
Forma que isso teria
Uma vez delegado, o limite por run não precisa de acesso a cgroup dentro do sandbox — basta o processo entrar no cgroup antes do exec:
sh -c 'echo $$ > /sys/fs/cgroup/judge/run_N/cgroup.procs; exec bwrap ...'
O sh externo se move, e o bwrap e todos os filhos herdam.
Por que não fiz agora
É mudança de plumbing de container, não de código PHP: mexe no entrypoint, na delegação, na dança de "no internal processes", e precisa de um caminho de fallback quando o cgroup não está disponível. Não consigo validar aqui que se comporta igual num host Linux de verdade, na VM do Docker Desktop e no runner do GitHub — e "funciona na minha máquina" é exatamente o que não serve para um limite de memória.
O que fica valendo até lá
Depois da #77 o sandbox aplica ulimit -t (CPU), -f (arquivo) e -u (processos). Memória residente depende só do flag {memory} por linguagem — que existe para Java e Kotlin (-Xmx) e não existe para C, C++, Rust, Go e Pascal. Um malloc em laço numa submissão em C não tem teto nenhum além da RAM da máquina.
Sem ulimit -v de propósito: JVM, Go e Rust reservam faixas grandes de endereçamento virtual no startup e morreriam independentemente da memória que realmente tocam. Um teto de address space não é um teto de memória.
Escopo que sobra
Delegação de cgroup v2 no entrypoint do Dockerfile.judge, com a dança de "no internal processes"
memory.max por run, e fallback explícito (falhar fechado? avisar?) quando o cgroup não estiver disponível
Mapear o OOM kill para MLE, não para RE genérico — hoje não há verdict de memória em lugar nenhum do caminho do juiz
Estender o JudgeSandboxConfinementTest com um caso de estouro de memória: ele hoje afere tempo, disco e processos, e não memória
changed the title [-]Judge sandbox: cap resident memory with cgroups, and remove safeexec[/-][+]Judge sandbox: cap resident memory with cgroups[/+]on Sep 13, 2026
A #98 fechou a parte que dava para fechar sem mexer em como o container é implantado.
O buraco era real e está medido: uma submissão em C alocando em laço passava de 4 GB e continuava. Isso valia para toda submissão em C, C++, Pascal e Rust — as linguagens que não têm placeholder {memory} no run_command.
Agora essas ganham ulimit -v, e sob o teto o malloc falha em 240 MB e o equivalente em Python levanta MemoryError. A suíte de confinamento afere isso direto, que era o último item da lista desta issue.
A lista de linguagens é medida, não presumida: JVM, Go e V8 se recusam a subir sob ulimit -v porque ele limita espaço de endereçamento, não memória residente.
O que continua aberto aqui
Duas coisas que só o cgroup resolve:
Go, Node, TypeScript e C# seguem sem teto. As três primeiras reservam faixa virtual grande no startup; a quarta provavelmente igual. Dá para amenizar com flags de runtime (GOMEMLIMIT, --max-old-space-size, DOTNET_GCHeapHardLimit) em vez de cgroup — é mais barato e vale considerar antes.
Não existe verdict MLE. Uma falha de alocação sob ulimit -v é indistinguível de qualquer outro erro de execução: vira RE genérico. Só um OOM kill por cgroup dá o sinal necessário para dizer "estourou memória" em vez de "seu programa quebrou".
O caminho do cgroup e a medição de por que ele não é trivial neste container continuam no comentário anterior.
Pesquisei isto com fontes primárias antes de continuar, e o resultado derruba duas conclusões que eu mesmo escrevi nesta issue. Registro tudo, inclusive o que eu errei.
O que os juízes maduros fazem
projeto
mecanismo
privilégio
sinal de MLE
isolate (IOI)
cgroup v2 apenas, memory.max
setuid root + subtree delegado por systemd
memory.events → oom_kill
DOMjudge ≥9
cgroup v2 via libcgroup. RLIMIT_AS explicitamente RLIM_INFINITY — "Memory limits should be unlimited, since we use cgroups"
root no host, sudo -n; imagem oficial roda --privileged --cgroupns=host
não tem.memory-bytes é só exibição; OOM vira run-error
CMS
delega ao isolate (--cg-mem)
o que o isolate exigir; compose próprio usa privileged: true
SG + cg-oom-killed → EXIT_MEM_LIMIT
Judge0
fork antigo do isolate, cgroup v1
privileged: truee boot do host com systemd.unified_cgroup_hierarchy=0 no GRUB
lê VmHWM de /proc/<pid>/status; is_mle = max_memory > limit
O manual do isolate é categórico sobre container:
"Running Isolate in containers is not recommended, since container managers usually do not delegate control groups properly. [...] If you still want to use containers, you are on your own and you probably have to make them privileged."
Os dois erros meus
1. "Não existe verdict MLE sem cgroup." Falso. O DMOJ emite MLE sem cgroup nenhum, comparando pico de RSS com o limite. É verdade para o DOMjudge — confirmei que nem no main atual ele tem verdict de memória — mas não para juízes em geral.
2. "ulimit -v é inutilizável para JVM, Go e V8." A observação estava certa, a conclusão era forte demais. O DMOJ roda os três em produção com RLIMIT_AS — só que com folga, e parando de usar o rlimit como fonte do veredicto. A tabela de folga deles corrobora nossa própria medição de forma independente:
linguagem
folga sobre o limite
padrão
64 MB
Go
768 MB
Java
768 MB (e RLIMIT_DATA desligado)
Node
1024 MB
C#/Mono
256 MB
Python
128 MB
Nossa medição de "V8 falha silenciosamente abaixo de ~1 GB" e o address_grace = 1048576 do DMOJ para Node são o mesmo número, obtidos de forma independente.
Duas coisas que nem o cgroup resolve
O memory.max não garante OOM kill. Manual do isolate:
"If it happens during memory allocation, the allocation can fail or memory can be over-committed by the kernel. If it happens when handling a page fault, the whole process is killed by the OOM killer."
Ou seja: o caminho do malloc() devolvendo NULL continua virando RE genérico mesmo no CMS, que só checa SG + cg-oom-killed. Cgroup compra um MLE quase sempre correto, não sempre.
O cgroup não elimina o ajuste por linguagem. O DOMjudge, que usa cgroup, ainda assim fixa -Xms/-Xmx/-Xss e MEMJVM=65536 nos wrappers de Java.
Sobre o nosso bloqueio de delegação
A metade do "no internal processes"tem receita documentada — está no manual do kernel:
"the cgroup must create children and transfer all its processes to the children before enabling controllers in its cgroup.subtree_control file"
O isolate-cg-keeper.c é a implementação de referência, em 130 linhas: cria <cg>/daemon, move o próprio pid para lá, só então escreve +memory no subtree_control do pai.
Mas a outra metade não tem saída: o Docker monta /sys/fs/cgroupread-only a menos que o container seja privilegiado, e o moby#46763 — "Ability to mount cgroupfs as read/write with --cgroupns=private" — está aberto desde 2023. --cgroupns=private sozinho não ajuda.
As opções reais, em ordem decrescente de privilégio:
--privileged --cgroupns=host — o que todos os projetos citados de fato entregam;
-v /sys/fs/cgroup:/sys/fs/cgroup:rw sem privileged — funciona, mas expõe a árvore de cgroup do host inteiro ao container (DoS host-wide);
Podman é o único runtime com resposta de primeira classe (--systemd=always monta writable; rootless delega memory pids por padrão).
Recomendação: copiar o DMOJ, não o isolate
Concretamente, e sem nenhuma mudança de privilégio:
ulimit -v para todas as linguagens, com folga por linguagem (a tabela do DMOJ é calibração pronta e testada em produção). O rlimit vira barreira de segurança, não fonte do veredicto.
Medir o pico de RSS e emitir MLE se pico > limite, independente de como o processo morreu. Isso dá verdict MLE hoje, para toda linguagem incluindo Go.
Já verifiquei na imagem do juiz que o /usr/bin/time -f '%M' (busybox) reporta 211016 KB para uma alocação de 200 MB. O instrumento existe.
O custo, dito sem enfeite:ulimit -v com folga é teto mole — um programa Go pode tocar limite + 768 MB de address space antes de a gente perceber. Numa máquina julgando uma submissão por vez é chateação; com 8 julgamentos concorrentes é risco real de over-commit, e aí precisa de teto de concorrência ou um -m no container como rede. E ru_maxrss é máximo entre processos, não soma — o nosso ulimit -u é o que cobre isso, exatamente como no DMOJ.
Vou implementar isso. Esta issue continua aberta só para o teto duro por cgroup, que agora tem um preço explícito: --privileged no container do juiz, ou expor a árvore de cgroup do host. Essa continua sendo decisão de vocês, mas deixou de ser bloqueante — o MLE e o teto para Go chegam sem ela.
Activity
matbrgz commentedon Sep 13, 2026
A #96 remove o safeexec e o resto do modelo pré-sandbox. A metade de cgroups fica aberta, e aqui está o que eu medi para quem for pegar, em vez de deixar a pessoa começar do zero.
O que dá e o que não dá hoje
Na imagem do juiz, com
--privileged:O controlador
memoryestá disponível. Mas:O container roda como
judge(oDockerfile.judgetemUSER judge) e a raiz do cgroup é do root. Tentando delegar como root no entrypoint:Isso falha pela regra "no internal processes" do cgroup v2: não dá para habilitar controlador num cgroup que já tem processos membros. O caminho documentado é mover os processos existentes para um cgroup folha (
/sys/fs/cgroup/init) antes de habilitar o controlador na raiz — e só então criar e delegar/sys/fs/cgroup/judgepara o uid do juiz.Forma que isso teria
Uma vez delegado, o limite por run não precisa de acesso a cgroup dentro do sandbox — basta o processo entrar no cgroup antes do
exec:sh -c 'echo $$ > /sys/fs/cgroup/judge/run_N/cgroup.procs; exec bwrap ...'O
shexterno se move, e o bwrap e todos os filhos herdam.Por que não fiz agora
É mudança de plumbing de container, não de código PHP: mexe no entrypoint, na delegação, na dança de "no internal processes", e precisa de um caminho de fallback quando o cgroup não está disponível. Não consigo validar aqui que se comporta igual num host Linux de verdade, na VM do Docker Desktop e no runner do GitHub — e "funciona na minha máquina" é exatamente o que não serve para um limite de memória.
O que fica valendo até lá
Depois da #77 o sandbox aplica
ulimit -t(CPU),-f(arquivo) e-u(processos). Memória residente depende só do flag{memory}por linguagem — que existe para Java e Kotlin (-Xmx) e não existe para C, C++, Rust, Go e Pascal. Ummallocem laço numa submissão em C não tem teto nenhum além da RAM da máquina.Sem
ulimit -vde propósito: JVM, Go e Rust reservam faixas grandes de endereçamento virtual no startup e morreriam independentemente da memória que realmente tocam. Um teto de address space não é um teto de memória.Escopo que sobra
Dockerfile.judge, com a dança de "no internal processes"memory.maxpor run, e fallback explícito (falhar fechado? avisar?) quando o cgroup não estiver disponívelJudgeSandboxConfinementTestcom um caso de estouro de memória: ele hoje afere tempo, disco e processos, e não memóriafix(judge): remove safeexec and the rest of the pre-sandbox model (#96)
[-]Judge sandbox: cap resident memory with cgroups, and remove safeexec[/-][+]Judge sandbox: cap resident memory with cgroups[/+]feat(judge): cap memory per language, where an rlimit is actually a c…
matbrgz commentedon Sep 13, 2026
A #98 fechou a parte que dava para fechar sem mexer em como o container é implantado.
O buraco era real e está medido: uma submissão em C alocando em laço passava de 4 GB e continuava. Isso valia para toda submissão em C, C++, Pascal e Rust — as linguagens que não têm placeholder
{memory}norun_command.Agora essas ganham
ulimit -v, e sob o teto omallocfalha em 240 MB e o equivalente em Python levantaMemoryError. A suíte de confinamento afere isso direto, que era o último item da lista desta issue.A lista de linguagens é medida, não presumida: JVM, Go e V8 se recusam a subir sob
ulimit -vporque ele limita espaço de endereçamento, não memória residente.O que continua aberto aqui
Duas coisas que só o cgroup resolve:
GOMEMLIMIT,--max-old-space-size,DOTNET_GCHeapHardLimit) em vez de cgroup — é mais barato e vale considerar antes.ulimit -vé indistinguível de qualquer outro erro de execução: vira RE genérico. Só um OOM kill por cgroup dá o sinal necessário para dizer "estourou memória" em vez de "seu programa quebrou".O caminho do cgroup e a medição de por que ele não é trivial neste container continuam no comentário anterior.
feat(judge): cap memory for Node, TypeScript and C# through their run…
matbrgz commentedon Sep 13, 2026
Pesquisei isto com fontes primárias antes de continuar, e o resultado derruba duas conclusões que eu mesmo escrevi nesta issue. Registro tudo, inclusive o que eu errei.
O que os juízes maduros fazem
memory.maxmemory.events→oom_killRLIMIT_ASexplicitamenteRLIM_INFINITY— "Memory limits should be unlimited, since we use cgroups"sudo -n; imagem oficial roda--privileged --cgroupns=hostmemory-bytesé só exibição; OOM virarun-error--cg-mem)privileged: trueSG+cg-oom-killed→EXIT_MEM_LIMITprivileged: truee boot do host comsystemd.unified_cgroup_hierarchy=0no GRUBsetrlimit(RLIMIT_AS, limit + grace)VmHWMde/proc/<pid>/status;is_mle = max_memory > limitO manual do isolate é categórico sobre container:
Os dois erros meus
1. "Não existe verdict MLE sem cgroup." Falso. O DMOJ emite MLE sem cgroup nenhum, comparando pico de RSS com o limite. É verdade para o DOMjudge — confirmei que nem no
mainatual ele tem verdict de memória — mas não para juízes em geral.2. "
ulimit -vé inutilizável para JVM, Go e V8." A observação estava certa, a conclusão era forte demais. O DMOJ roda os três em produção comRLIMIT_AS— só que com folga, e parando de usar o rlimit como fonte do veredicto. A tabela de folga deles corrobora nossa própria medição de forma independente:RLIMIT_DATAdesligado)Nossa medição de "V8 falha silenciosamente abaixo de ~1 GB" e o
address_grace = 1048576do DMOJ para Node são o mesmo número, obtidos de forma independente.Duas coisas que nem o cgroup resolve
O
memory.maxnão garante OOM kill. Manual do isolate:Ou seja: o caminho do
malloc()devolvendo NULL continua virando RE genérico mesmo no CMS, que só checaSG+cg-oom-killed. Cgroup compra um MLE quase sempre correto, não sempre.O cgroup não elimina o ajuste por linguagem. O DOMjudge, que usa cgroup, ainda assim fixa
-Xms/-Xmx/-XsseMEMJVM=65536nos wrappers de Java.Sobre o nosso bloqueio de delegação
A metade do "no internal processes" tem receita documentada — está no manual do kernel:
O
isolate-cg-keeper.cé a implementação de referência, em 130 linhas: cria<cg>/daemon, move o próprio pid para lá, só então escreve+memorynosubtree_controldo pai.Mas a outra metade não tem saída: o Docker monta
/sys/fs/cgroupread-only a menos que o container seja privilegiado, e o moby#46763 — "Ability to mount cgroupfs as read/write with--cgroupns=private" — está aberto desde 2023.--cgroupns=privatesozinho não ajuda.As opções reais, em ordem decrescente de privilégio:
--privileged --cgroupns=host— o que todos os projetos citados de fato entregam;-v /sys/fs/cgroup:/sys/fs/cgroup:rwsem privileged — funciona, mas expõe a árvore de cgroup do host inteiro ao container (DoS host-wide);--systemd=alwaysmonta writable; rootless delegamemory pidspor padrão).Recomendação: copiar o DMOJ, não o isolate
Concretamente, e sem nenhuma mudança de privilégio:
ulimit -vpara todas as linguagens, com folga por linguagem (a tabela do DMOJ é calibração pronta e testada em produção). O rlimit vira barreira de segurança, não fonte do veredicto.Já verifiquei na imagem do juiz que o
/usr/bin/time -f '%M'(busybox) reporta 211016 KB para uma alocação de 200 MB. O instrumento existe.O custo, dito sem enfeite:
ulimit -vcom folga é teto mole — um programa Go pode tocarlimite + 768 MBde address space antes de a gente perceber. Numa máquina julgando uma submissão por vez é chateação; com 8 julgamentos concorrentes é risco real de over-commit, e aí precisa de teto de concorrência ou um-mno container como rede. Eru_maxrssé máximo entre processos, não soma — o nossoulimit -ué o que cobre isso, exatamente como no DMOJ.Vou implementar isso. Esta issue continua aberta só para o teto duro por cgroup, que agora tem um preço explícito:
--privilegedno container do juiz, ou expor a árvore de cgroup do host. Essa continua sendo decisão de vocês, mas deixou de ser bloqueante — o MLE e o teto para Go chegam sem ela.Fontes
isolate(1) · isolate cg.c · isolate-cg-keeper.c · DOMjudge runguard.cc · CMS Sandbox.py · DMOJ _cptbox.pyx · DMOJ GO.py · DMOJ NODEJS.py · kernel cgroup-v2 · moby#46763 · Judge0 compose
feat(judge): an MLE verdict, from measured peak memory (#86) (#108)
feat(judge): cgroup v2 memory limits for the sandbox (#86) (#114)
fix(judge): cap resident memory during compilation too (#115) (#118)
fix(docker): the app image must not run submitted code as root (#136)
fix(docker): the app image must not run submitted code as root (#136) (…
Autoteste do sandbox na maquina de julgamento (#194) (#210)
Tempo medido por maquina, fase 1: medir e avisar (issue #196) (#230)
O silêncio de uma máquina que não pode julgar vira mensagem (issue #282…