Source code for toil.lib.interpreter
"""
Shared code for workflow language interpreters implemented with Toil.
"""
# Copyright (C) 2026 Regents of the University of California
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import glob
import logging
import os
import platform
import shlex
import textwrap
from typing import Iterable
from toil.lib.resources import ResourceMonitor
logger = logging.getLogger(__name__)
####
# Shell command injection system
#
# When a workflow steps runs inside a container like Docker, where the contained
# process is not a descendant of Toil, the Toil worker process on the host cannot
# see how much CPU and RAM that step actually used.
#
# This system allows Toil to inject code into the container that will colklect
# and export resource usage information back to Toil.
#
# It also allows Toil to do other checks on the container system.
###
# Code injected into containers container communicates back to the rest of Toil
# through files in a directory named this.
# Where the directory actually is is interpreter-specific.
INJECTED_MESSAGE_DIR = ".toil_runtime"
# We mostly want to work with shell script strings, but CWL works with command
# argument lists, so we use these functions to convert back and forth.
[docs]
def command_line_to_shell_script(command_line: list[str]) -> str:
"""
Extract or synthesize the inner shell script from a cwltool argv list.
We don't want to disturb user CWL command line binding lists that
explicitly ask for things like ["bash", "-c"].
"""
return " ".join(shlex.quote(arg) for arg in command_line)
[docs]
def shell_script_to_command_line(script: str) -> list[str]:
"""
Wrap a shell script as a list of arguments for launchign a process.
The resulting command only requires a POSIX shell as sh.
"""
return ["sh", "-c", script]
# Main function
[docs]
def add_injections(
command_string: str,
file_mounts: Iterable[tuple[str, str]],
message_dir: str,
) -> str:
"""
Add resource usage monitoring and file mount checking code to a command.
The command is expected to be about to run in a container, under a
container system that does not itself attribute resource usage to the
calling Toil process (such as Docker, which uses a daemon).
All shell code added is compatible with POSIX sh.
:param command_string: shell command or script to modify
:param file_mounts: collection of (host path, container path) tuples for
files mounted into the container. Code will be added to require that
the container sees the complete file that the host sees.
:param message_dir: directory, absolute or relative to the working
directory that the command should record resource usage to
:returns: shell command string (possibly containing multiple commands) that
runs the original command and reports resource usage.
"""
parts = []
# We're running on Docker or another platform where Toil isn't an ancestor
# process, so we need to monitor CPU usage and so on from inside the
# container, since it won't be attributed to Toil child processes in the
# leader's self-monitoring.
# TODO: Mount this script from a file Toil installs instead or something
# instead of injecting it in every command line, which makes it show up in
# logs.
# Use https://www.shellcheck.net/ with #!/bin/sh at the top to make sure
# this is all sh-compatible.
script = textwrap.dedent(
"""\
_toil_resource_monitor () {
# Turn off error checking and echo in here
set +ex
TOIL_MESSAGE_DIR="${1}"
mkdir -p "${TOIL_MESSAGE_DIR}"
get_field () {
INFILE="${1}"
FIELD_NAME="${2}"
while IFS=' ' read -r KEY VALUE ; do
if [ "${KEY}" = "${FIELD_NAME}" ] ; then
echo "${VALUE}"
return
fi
done < "${INFILE}"
}
sample_cpu_usec() {
if [ -f /sys/fs/cgroup/cpu.stat ] ; then
get_field /sys/fs/cgroup/cpu.stat usage_usec
elif [ -f /sys/fs/cgroup/cpuacct/cpuacct.stat ] ; then
echo $(( ( $(get_field /sys/fs/cgroup/cpuacct/cpuacct.stat user) + $(get_field /sys/fs/cgroup/cpuacct/cpuacct.stat system) ) * 10000 ))
fi
}
sample_memory_bytes() {
if [ -f /sys/fs/cgroup/memory.stat ] ; then
get_field /sys/fs/cgroup/memory.stat anon
elif [ -f /sys/fs/cgroup/memory/memory.stat ] ; then
get_field /sys/fs/cgroup/memory/memory.stat total_rss
fi
}
while true ; do
(printf "CPU\\t" ; sample_cpu_usec ; printf "Memory\\t" ; sample_memory_bytes) >> "${TOIL_MESSAGE_DIR}"/resources.tsv
sleep 1
done
}
"""
)
parts.append(script)
# Launch in a subshell so that it doesn't interfere with "wait" in the
# main shell.
#
# We need another subshell as a background job, so we can get the PID which
# is used as the PGID for the background process group, so we can stop it
# later without job control.
parts.append(f"(set +m; (_toil_resource_monitor {message_dir} &) ) &")
# We use a loop to try and wait on the whole process group the background
# monitoring is in by polling it, to make sure it's gone before we exit.
# See <https://github.com/chanzuckerberg/miniwdl/issues/902>
#
# TODO: What if the user also traps EXIT? Then this won't work.
# TODO: This also doesn't (always? ever?) work to prevent Docker Swarm from
# reporting a container as running with an exit code.
stopper_script = textwrap.dedent(
"""\
TOIL_BG_PGID="${!}"
wait
trap "while kill -0 -${TOIL_BG_PGID} 2>/dev/null ; do kill -9 -${TOIL_BG_PGID} 2>/dev/null ; done" EXIT
"""
)
parts.append(stopper_script)
if platform.system() == "Darwin":
# With gRPC FUSE file sharing, files immediately downloaded before
# being mounted may appear as size 0 in the container due to a race
# condition. Check for this and produce an approperiate error.
# We can't guarantee that the container has a stat binary, and we can't
# measure the size of a file with pure shell without reading it all, so
# we poll for stat and skip the check if it isn't there.
script = textwrap.dedent(
"""\
_toil_check_size () {
if ! which stat >/dev/null 2>/dev/null ; then
return
fi
TARGET_FILE="${1}"
GOT_SIZE="$(stat -c %s "${TARGET_FILE}")"
EXPECTED_SIZE="${2}"
if [ "${GOT_SIZE}" != "${EXPECTED_SIZE}" ] ; then
echo >&2 "Toil Error:"
echo >&2 "File size visible in container for ${TARGET_FILE} is size ${GOT_SIZE} but should be size ${EXPECTED_SIZE}"
echo >&2 "Are you using gRPC FUSE file sharing in Docker Desktop?"
echo >&2 "It doesn't work: see <https://github.com/DataBiosphere/toil/issues/4542>."
exit 1
fi
}
"""
)
parts.append(script)
for host_path, job_path in file_mounts:
expected_size = os.path.getsize(host_path)
if expected_size != 0:
parts.append(f'_toil_check_size "{job_path}" {expected_size}')
parts.append(command_string)
return "\n".join(parts)
# Helper functions to parse resource usage output
[docs]
def handle_message_file(file_path: str) -> None:
"""
Handle a message file received from injected code from :meth:`add_injections()`.
Records the described usage as if it had occurred within a child process,
by talking to the :class:`toil.lib.resources.ResourceMonitor` system.
:param file_path: the host-side path of the message file.
"""
logger.debug("Handling message file %s", file_path)
if os.path.basename(file_path) == "resources.tsv":
# This is a TSV of resource usage info.
first_cpu_usec: int | None = None
last_cpu_usec: int | None = None
max_memory_bytes: int | None = None
for line in open(file_path):
if not line.endswith("\n"):
# Skip partial lines
continue
# For each full line we got
parts = line.strip().split("\t")
if len(parts) != 2:
# Skip odd-shaped lines
continue
if parts[0] == "CPU":
# Parse CPU usage
cpu_usec = int(parts[1])
# Update summary stats
if first_cpu_usec is None:
first_cpu_usec = cpu_usec
last_cpu_usec = cpu_usec
elif parts[0] == "Memory":
# Parse memory usage
memory_bytes = int(parts[1])
# Update summary stats
if max_memory_bytes is None or max_memory_bytes < memory_bytes:
max_memory_bytes = memory_bytes
if max_memory_bytes is not None:
logger.info(
"Container used at about %s bytes of memory at peak",
max_memory_bytes,
)
# Treat it as if used by a child process
ResourceMonitor.record_extra_memory(max_memory_bytes // 1024)
if last_cpu_usec is not None:
assert first_cpu_usec is not None
cpu_seconds = (last_cpu_usec - first_cpu_usec) / 1000000
logger.info("Container used about %s seconds of CPU time", cpu_seconds)
# Treat it as if used by a child process
ResourceMonitor.record_extra_cpu(cpu_seconds)
[docs]
def handle_injection_messages_from(message_dir: str) -> None:
"""
Handle any message files in the given directory.
Files would have been left by injected code from :meth:`add_injections()`.
"""
if not os.path.isdir(message_dir):
# It's possible for the container to never have made this directory
logger.debug("No message files found")
return
for filename in os.listdir(message_dir):
file_path = os.path.join(message_dir, filename)
if os.path.isfile(file_path):
handle_message_file(file_path)