Revision history for SimpleFlow
0.195 2026-10-07 (Claude Opus 5.5 helped)
[Tests]
- **Two tests no longer assume that the wall clock keeps pace with
`sleep`.** `t/01.t` checked that `overwrite => 'true'` rewrites its
output by comparing the file's `-M` before and after a `sleep 1`; on a
perl 5.10.1 CPAN smoker running in a VM, both readings fell in the
second the test started although the file had been rewritten, and the
test died "output files are not overwritten". It now checks the file's
contents, which the re-run replaces. Block 12 of `t/02.fixes.t` made
its input newer than its output with a `sleep 2` between the writes,
and now sets both times with `utime`. The module is unchanged.
0.194 2026-10-05 (Claude Opus 5.5 helped)
[Fixed]
- **`parallel()` did not pass an interrupt on to its steps when the caller
ignored TERM.** An interrupt sent to `parallel()`'s process was passed to
every running step as TERM, and the steps, like their commands, had
inherited TERM ignored. So an INT or a HUP sent to that process alone,
by `kill` or a batch scheduler, reached no step: each ran on to its
end, and only then was the interrupt re-raised. A Ctrl-C at the
terminal was not affected, because it reaches the steps directly. Each
step is now passed the signal that `parallel()` received, which it
cannot be ignoring. A step stopped by an INT or a QUIT that way now
returns its record, or its error, saying its command was killed by that
signal; it used to die of the TERM itself, and `parallel()` reported
only that it "ended before it could return a record".
`t/10.ignored.signals.t` tests this; against 0.193 each of its two
cases took 30 s, the whole of the steps' sleep.
[Tests]
- **The suite no longer fails when it is run with signals ignored,** as it
is under a background job: `( cpanm SimpleFlow & )` in a script starts
cpanm with SIGINT and SIGQUIT ignored, and every test inherits that.
0.193 could not be installed that way: `t/04.fixes.t` test 15 failed
"the calling program was ended by the interrupt" (got 0, expected 2).
The module was right to leave an ignored signal ignored, as `system()`
does; four tests, in `t/01.t`, `t/04.fixes.t` and `t/06.pipeline.t`,
relied on a signal's default action without setting it, and with TERM
ignored those four failed too. Each now sets the default itself; this
fix is to the tests alone.
- `t/10.ignored.signals.t` checks that a signal the caller ignores stays
ignored by perl and by the command, under `task()` with and without
`timeout` and under `parallel()`, and re-runs every other test file with
HUP, INT, QUIT, TERM and TSTP inherited ignored. It fails against
0.193's tests, and roughly doubles the time the suite takes.
0.193 2026-10-03 (Claude Opus 5.5 helped)
[Changed]
- **Incompatible: every argument whose name held a dot now has an
underscore instead.** The arguments of `task()` are `conda_env`,
`container_args`, `container_engine`, `dry_run`, `env_secret`,
`executor_args`, `input_dir`, `input_dirs`, `input_file`,
`input_files`, `log_fh`, `on_failure`, `on_success`, `output_dir`,
`output_dirs`, `output_file`, `output_files`, `retry_delay`,
`stale_cmd`, `stderr_file`, `stdout_file` and `trace_fh`, and that of
`parallel()` is `keep_going`. A dotted name is refused as an unknown
argument, in a call and in `%SimpleFlow::DEFAULTS` alike. The options
copied onto the returned record and into a trace take the new names,
as do `input_files` and `output_files`, and `input.file.size` and
`output.file.size` become `input_file.size` and `output_file.size`.
The record's other fields, such as `will.do`, `start.time` and
`failed.outputs`, are not arguments and keep their names. A
`stale_cmd` digest recorded by 0.192 is still valid.
0.192 2026-10-02 (Claude Opus 5.5 helped)
[Fixed]
These were reported in an independent review of 0.191 by Joshua S. Day
(Astra-Review.md, github.com/haxmeister/SimpleFlow, commit 5e9b03d).
- **Moving a failed step's outputs aside could destroy them.** A name
declared twice had its `.failed` deleted by its own second pass; an
output named like another's `.failed`, as `out` and `out.failed`, was
deleted to make room for the other; and a file inside a declared
directory was reported under a name that no longer existed. Each path is
now moved once, a file inside a moved directory goes with it under its
own name, and the longest names are moved first.
- **`parallel()` could write a broken trace.** Its workers wrote through one
inherited handle with nothing to keep them apart, and a long trace line
could be split by another worker's, which `report()` then refused. Each
worker now writes its record holding a lock.
- **`stale.cmd` could take one command's outputs for another's.** A run
without `stale.cmd` that replaced the outputs left the previous command
on record; it now records its own when there is a record to replace.
The digest also ignored the word boundaries of a wrapper, container or
executor argument holding a space, and ignored `threads` on a local
step; both now count.
- **`lock` died on an output name with a character above 255**, "Wide
character in subroutine entry".
- **`stdout.file` or `stderr.file` lost earlier attempts when it was also an
output.** A failed attempt moved it aside, so a retry began a new file.
Such an output now stays in place until the step has failed for good.
- **The failure message left out stderr when `stderr.file` was an output,**
because the file had been moved aside before it was read.
- **A `STDIN` on an in-memory scalar made `task()` die** "cannot restore
STDIN" on perl 5.44.0, after the command had succeeded. A `STDIN` that
is not descriptor 0 is now left alone, and descriptor 0 itself is
redirected and put back.
- **`timeout` never fired for a caller that had raised `$^F`,** which gave
the command the pipe that reports a failed exec, so `task()` waited for
the command to end before it set the alarm. The pipe is now marked
close-on-exec explicitly.
- **`parallel()` waited for ever when the caller ignored `SIGCHLD`,** or
reaped its children itself. A worker that is no longer there to wait for
now counts as finished.
- **A worker in `parallel()` that failed to store its record ran on** as a
second copy of the caller's program; it now always exits.
- **Without a timeout, a signal reached only the command's first
process.** A `TERM` or `HUP` sent to the caller went to the shell at the
head of a pipeline, and the rest ran on as orphans; an `INT` sent to the
caller alone, by `kill`, was ignored. Every command now leads a process
group of its own, as a timed one already did, and a signal is passed to
the group. Ctrl-Z, and a command that opens the terminal itself, are
handled as a shell handles a job.
- **A die from one of the caller's own signal handlers left the command
running**, never waited for. Its group is now killed and reaped first.
- **`.simpleflow/` and its files were used through symbolic links.** A
link at `.simpleflow` or `.simpleflow/cmd` is now refused, the lock
files and command records are opened without following one, and a
command record is staged under a `File::Temp` name rather than a
predictable `<record>.<pid>` that was opened with truncation.
- **`protect` tested for a symbolic link and then changed the permission
by name,** so a link swapped in between had its target changed. It now
changes a handle opened without following links, where it can open one.
- **`lock` treated two names for one output as two outputs,** such as `out`
and `sub/../out`, and a directory output did not exclude a step whose
output was a file inside it.
- **`report()` read more than JSON:** bytes that were not UTF-8, raw
control characters, unpaired surrogates and unbounded nesting, and it
died "isn't numeric" on a time that was not a number. Each is now
refused with a message naming the line and what is wrong with it.
- **`report()` encoded a `title` given as UTF-8 bytes a second time.**
[Added]
- `env.secret`, the names of `env` variables whose values are shown as
`(secret)` in the record, the log, the trace and argument errors. The
record has an `env.secret` field on every path.
[Changed]
- Without a `timeout`, a command now has its own process group, and a
Ctrl-C at the terminal reaches the caller, which passes it on, rather
than reaching the command directly. The step's result is the same.
- A lock is named for the output's real path, so the lock files of 0.192
differ from those of 0.191, and the two do not see each other's locks.
- A `stale.cmd` step that sets `threads` on a local executor, or whose
wrapper, container or executor arguments hold a word with a space, has
a new digest, and runs once more after the upgrade. Every other digest
0.191 recorded is still valid.
- A step run without `stale.cmd` now writes `.simpleflow/cmd/` when, and
only when, a record for its outputs is already there.
- `failed.outputs` no longer lists a file inside a directory output that
was moved aside; the directory's own entry covers it.
[Documentation]
- The tables of arguments and of the record's fields were HTML only, and
`perldoc`, `pod2text` and man pages showed neither. `md2pod.pl` now also
writes each as a POD list for those readers.
- Installing from a git checkout needs `dzil build` first; the checkout has
no `Makefile.PL`.
- `SECURITY.md` says what SimpleFlow trusts: the working directory, the
trace `report()` reads, and what a record writes out.
[Tests]
- `t/09.fixes.t` covers each of the above, and fails against 0.191.
0.191 2026-09-27 (Claude Opus 5.5 helped)
[Fixed]
- **A signal sent just as the command started could be lost.** 0.19 passes a
TERM or HUP (and, under `timeout`, an INT or QUIT) to the command, but
only once its handlers were installed, which was after the exec had
succeeded. A signal that arrived in between went to the caller's handler
alone: the command was neither killed nor sent it, ran to its end, and
was reported as `done`. A CPAN smoker (perl 5.16.3 on Alpine) hit this
in `t/07.coverage.t`. Those signals are now blocked from before the fork
until the handlers are in place, and one that arrived meanwhile is then
delivered to them.
[Tests]
- `t/08.fixes.t` sends the signal inside that window deterministically, and
fails against 0.19.
- `t/04.fixes.t` and `t/05.features.t` no longer draw "Statement unlikely to
be reached" from perl 5.16.
- Seven subtests failed on a Strawberry Perl 5.42.0 smoker; the module was
not at fault in any of them. `t/05.features.t` and `t/07.coverage.t`
handed a child perl code with a double quote in it, which MSWin32's
`system(LIST)` does not escape; `t/05.features.t`'s wrapper used `exec`,
which on MSWin32 ends the wrapper before the command has printed; and
`t/06.pipeline.t` ran `parallel()` with `jobs` above 1, which is refused
there by design. Those subtests now check the refusal instead, and the
tests' helpers refuse a double quote in a list `cmd` or `wrapper` on
every platform, so that mistake can no longer reach a Windows smoker
unseen.
[Documentation]
- A string `cmd` inside a wrapper is run by `cmd.exe /c` on MSWin32, not by
`/bin/sh -c` as the documentation said.
0.19 2026-09-26 (Claude Opus 5.5 helped)
[Fixed]
- **A command killed by a signal was reported as a success.** A death by
signal leaves the exit code at 0, and `will.do` looked only at `exit`,
`timed.out` and missing outputs, so an OOM kill or a Ctrl-C -- which
`system()` ignores in the parent and so leaves to the child -- came back
`will.do => 'done'` with no warning, and under the default `die => 1`
the pipeline went on to its next step. It now counts as `FAILED`, and
`task()` dies (or, under `die => 0`, warns) naming the signal. This
affected a list `cmd`, a string with no shell metacharacters, and a
string the shell execs directly; otherwise the shell reports 128 + the
signal as a non-zero exit, which was already caught.
- **A dry run could not get past the second step of a pipeline.** The input
files were checked before `dry.run` was, so a step whose input is an
earlier step's output -- which a dry run never makes -- died with "the
above files are missing or are not readable". Under `dry.run` a missing
input is now listed in what the dry run prints, and in the log, rather
than being fatal; its entry in `input.file.size` is undef. Outside a dry
run a missing input still dies.
- **A failed step's partial output was taken as done on the next run.** An
output file half-written before a non-zero exit, a kill by signal or a
timeout was left under its declared name, so the next run found it,
reported `done => 'before'`, and skipped the step for good. The existing
outputs of a failed step, including one whose sibling outputs are
missing, are now moved to `<file>.failed`, replacing any `.failed` left
from before, and the move is reported on `STDERR` and in the log.
`output.file.size` still gives the sizes the command wrote.
- **Loading SimpleFlow changed how the caller's own program died and
warned.** `use Devel::Confess 'color'` installed global `__DIE__` and
`__WARN__` handlers, so a caller's `die "message\n"` came back with a
stack trace appended, and code comparing `$@` with a string broke.
Devel::Confess is now switched on only for the length of each `task()` or
`say2()` call, and the caller's handlers are put back afterwards;
SimpleFlow's own errors and warnings keep their coloured stack traces.
- **A Ctrl-C during a timed command left the command running.** Under
`timeout` the command has its own process group, which is not the
terminal's, so the interrupt reached only perl, and the command ran on as
an orphan. `INT`, `TERM`, `HUP` and `QUIT` are now caught while it runs:
the group is killed, the record is written, and the signal is passed on
to the caller's handler, or ends the program if there is none. One the
caller ignores stays ignored.
- **Under `timeout` with `stdin => 'inherit'`, a command reading the
terminal was reported as timed out.** Outside the terminal's foreground
group it was stopped by `SIGTTIN` until the timeout killed it. It is now
given the foreground for the run, as a shell gives it to a job, and the
caller takes it back after. This has no test in the suite, since showing
it needs a pseudo-terminal; it was checked by hand under `script(1)`.
- **`timeout` cancelled the caller's own pending `alarm`.** It is now put
back when the command finishes, less the time taken, and delivered at
once if it fell due while the command ran.
- **`timeout` accepted `"5\n"` and non-ASCII digits.** The check was
`/^\d+$/`; a Unicode digit then died "isn't numeric" rather than with the
argument error. It is now ASCII digits to the end of the string.
- **`stale` compared whole-second mtimes,** so an input rewritten in the same
second as its output was not newer. The mtimes now come from
`Time::HiRes::stat`.
- **A step that failed in more than one way died naming only one.** A
missing output was checked first, so a step that also exited non-zero or
timed out said only that the output was missing. The message now names
every reason, including the exit code.
- **A command that could not be launched did not say why.** `exit` was
`-1` and `$!` was discarded. `stderr` now holds the reason, as a shell
would have printed it.
- **A dry run's record lacked `output.file.size` and was not logged.** It
now has every field the other paths have, and is printed and logged as
theirs are.
- **Under `die => 0` a step with a missing output logged its record twice,**
the first copy without `output.file.size`. It is now printed once.
- **A missing output was also reported as having 0 size.**
- **The dumps explaining an error went to `STDOUT`.** Only their header
lines went to `STDERR`, so a caller that redirected standard output lost
the arguments and file lists into its output file. They now go to
`STDERR`; the record printed after every step still goes to `STDOUT`.
- **A `TERM` or `HUP` to perl during a command without a `timeout` left
the command running.** `system()` shields its caller from `INT` and
`QUIT` only, so a signal sent to perl alone, by a batch scheduler or
`kill`, killed perl and orphaned the command. On POSIX the command is
now forked and waited for by `task()` itself, as it already was under a
`timeout`: the signal is passed on to the command, the command waited
for, the record written, and the signal passed on to the caller.
- **Ctrl-Z during a timed command reading the terminal hung until the
timeout.** The command was stopped and nothing noticed. It is now
suspended along with the caller, as a shell suspends a job, with the
timeout's clock stopped, and resumed with it. Checked by hand under
`script(1)`, since showing it needs a pseudo-terminal: before, the step
sat stopped until its 8 s timeout killed it; after, it read its input and
succeeded in 3 s.
- **A command that could not be launched under a `timeout` was `exit
127`.** The forked child had no way to hand back why; it now writes its
`errno` down a close-on-exec pipe, as perl's own `system()` does, and the
command is `exit -1` with the reason in `stderr`, with or without a
timeout.
[Changed]
- **Two of these fixes change what an existing pipeline sees.** A step
killed by a signal now stops a pipeline running under the default
`die => 1`, where it used to carry on. A failed step's outputs are no
longer under their declared names afterwards, so code run under
`die => 0` that reads a failed step's output must read
`<file>.failed` instead, or look in `failed.outputs`.
- **Under a `timeout`, a command that could not be launched is `exit -1`,
not `127`,** as it already was without one, and `stderr` says why. Code
that tested for 127 there should test for -1.
- **A program that relied on SimpleFlow to give it Devel::Confess loses
it.** Its own `die` and `warn` no longer carry stack traces; one that
wants them should `use Devel::Confess` itself.
- **The failure messages are worded differently.** Each is now
`"<cmd>" <reason>; <reason>, from <file> line <line>`, the reasons being
"exited N", "was killed by signal N", "was killed after exceeding its Ns
timeout" and "these output files should have been made but are missing:
...". A die under `die => 1` for a non-zero exit used to say
"failed from"; it now says "exited N". Under `die => 0` a missing output
is now a `warn` rather than a line printed to `STDERR`.
[Added]
- **`failed.outputs`**, a new field of the record: an array ref of the
`.failed` names a failed step's outputs were moved to, and `[]` on every
other path.
- **`retries` and `retry.delay`**: run a failed step again, up to `retries`
more times, waiting `retry.delay` seconds before each, as Nextflow's
`errorStrategy 'retry'` and Snakemake's `--retries` do. Each failed
attempt has its outputs moved aside and is reported on `STDERR` and in
the log. The record describes the last attempt, and a new field,
`attempts`, says how many there were. An interrupt is never retried.
- **`env`**: environment variables for the command alone, `undef` removing
one; the caller's `%ENV` is put back afterwards.
- **`dir`**: run the whole step, its file checks included, in another
directory; the caller is put back in its own afterwards, however
`task()` returns.
- **`stdout.file` and `stderr.file`**: send the command's output to a file
instead of holding it in the record, as Snakemake's `log:` does. The
files are emptied when the step starts and keep every attempt's output;
one file may be named for both.
- **`output.dir` and `output.dirs`**: directory outputs, as Snakemake's
`directory()`. A directory counts as made if it exists, is warned about
if empty, is moved aside when the step fails, and under `stale` is as
new as the newest thing in it.
- **`protect`**: make a step's outputs read-only once it succeeds, as
Snakemake's `protected()`, and refuse to re-run over them.
- **`trace.fh`**: one line of JSON per task, on every path, holding the
record without `stdout` and `stderr`, as Nextflow's `trace.txt` does.
- **`lock`**: a `flock` on each output, kept in `.simpleflow/` in the
working directory, so that a second copy of the pipeline reaching the
step waits for the first and then finds it done.
- **`%SimpleFlow::DEFAULTS`**: defaults for every `task()` in the program,
for any key a call leaves undefined, so that one line can dry-run, quiet
or log a whole pipeline. `env` is merged rather than replaced; keys that
name a particular step are refused.
- **`cpu.user`, `cpu.system` and `start.time`**, new fields of the record:
the CPU time the command spent, from `times`, and when its last attempt
started.
- **The end of stderr in a failure's message.** The message a failed
step dies or warns with now ends with the last six lines of its
standard error, read back from `stderr.file` if that is where it went.
- **`input.dir` and `input.dirs`**: directory inputs, which must exist
before the step runs, and under `stale` are as new as the newest thing
in them.
- **`stale.cmd`**: re-run a step whose command, `env`, or container,
conda environment, executor or wrapper has changed since it made its
outputs, as Snakemake's `params` and `code` rerun triggers do. A new
field, `cmd.changed`, says when that happened. What made each set of
outputs is kept as a digest in `.simpleflow/cmd/`.
- **`on.success` and `on.failure`**: code called with the record after a
command has run, before `task()` dies; in `%SimpleFlow::DEFAULTS`, a
pipeline's `onsuccess` and `onerror`.
- **`container`, `container.engine` and `container.args`**: run the
command in a docker, podman, singularity or apptainer container, with
the working directory mounted.
- **`conda.env`**: run the command with `conda run`.
- **`executor`, `executor.args`, `threads`, `mem` and `walltime`**: run the
command as a SLURM job step with `srun`, asking for the resources given.
`threads` is also given to the command as `SIMPLEFLOW_THREADS`.
- **`wrapper`**: run the command inside any other command. A new field,
`wrapped.cmd`, is the command as actually run.
- **`parallel()`**, exported on request: run independent steps at the same
time, at most `jobs` at once, each in a child of its own, and return
their records in order. A failure stops new steps, lets the running ones
finish, and dies; `keep.going` runs them all first. POSIX-only for
`jobs` above 1.
- **`report()`**, exported on request: an HTML page of a trace, with a row
and a timeline bar for every task and a count of each status.
- Every new option has its resolved value on the record, as the existing
ones do, except the hooks, which are code. The record therefore has
many more fields, printed after every step, and a pipeline that
compares whole records will see them.
[Tests]
- **The 0.181 suite failed one test on MSWin32.** `t/03.fixes.t` expected a missing command given as a list to
come back `exit => -1`, but on MSWin32 a failed spawn of a list does not
make `system` return -1: win32.c's `do_aspawn` sets the status to
255 * 256, so `exit` is 255. The test now expects 255 there.
0.181 2026-09-25 (Claude Opus 5 helped)
[Tests]
- **The 0.18 suite failed on MSWin32; the module itself is unchanged.**
`t/lib/CaptureStd.pm` read its capture files in binary mode, so every
line captured from a `:crlf` handle ended in CR LF, and `t/01.t`'s
`say2` comparison failed. `t/03.fixes.t` passed its child program and
the command under test to a fresh perl as arguments containing double
quotes, which `system(LIST)` on MSWin32 does not escape, so the child
never ran and blocks 1 and 3 failed. The child code now avoids double
quotes, the command goes through `%ENV`, and a missing command given as a
string, which MSWin32 retries through `cmd.exe`, is required only to
fail with a non-zero `exit` there rather than `-1`.
0.18 2026-09-24 (Claude Opus 5 helped)
[Fixed]
- **A command that could not be launched was reported as a success, and the
rest of the calling program ran twice.** `system()` forks and then execs;
when the exec fails, its child warns "Can't exec", which the module's
`use warnings FATAL => 'all'` turned into a die *in that forked child*.
The die unwound out of `task()` into the caller's program, which then ran
on as a second copy, while the parent was handed the copy's exit status.
`task(cmd => 'no-such-program', die => 0)` came back `exit => 0`,
`will.do => 'done'`, and every later step ran twice. The `timeout` path
had the same defect in its own fork. A command that cannot be launched is
now `exit => -1` (`127` under a `timeout`) and `FAILED`. A string with a
shell metacharacter in it was never affected, since the shell launches
the program and reports `127` itself.
- **A one-element array-ref `cmd` went through the shell.** An array ref is
documented as run without a shell, but `system(@list)` hands a list of one
to the shell, so `cmd => ['echo hi; rm x']` ran both commands. The array
form now always runs the named program directly.
- **A command finishing at the moment its `timeout` fired could be reported
as timed out, with `exit => -1`.** If the alarm arrived after the child had
been reaped but before it was cancelled, the group was killed anyway and a
second `waitpid` overwrote the real status. The group is now killed only
if the child has not already been reaped. The window is a few
instructions wide, and has no test.
[Performance]
- **`stdout` and `stderr` are captured without `Capture::Tiny`.** It slurped
each capture into a lexical and returned it through several list copies,
which perls before 5.20 do not share: a `task()` whose command printed
100 MB peaked at 498 MB RSS and took 0.53 s on perl 5.10.1. `task()` now
points descriptors 1 and 2 at temporary files itself, with `POSIX::dup2`,
and reads each file straight into the record: 108 MB and 0.17 s. The
capture is read back through the caller's own `STDOUT` and `STDERR`
layers, as `Capture::Tiny` did, so a caller that set
`:encoding(UTF-8)` still gets characters; standard descriptors the
caller had closed are plugged with the null device for the run and
closed again after.
- **Trailing whitespace is stripped by walking back from the end.** The
regex used before scanned the whole of `stdout` from the start, and on a
perl with copy-on-write copied it first: on a 100 MB capture, 0.13 s and
peak RSS from 107 MB to 205 MB on perl 5.44.0. End to end, a `task()`
producing 100 MB of `stdout` went from 306 MB peak RSS and 0.30 s to
111 MB and 0.12 s on 5.44.0.
- **Printing the record no longer copies the captured output.** The clipped
copy made for printing copied every field in full before clipping it,
which perls before 5.20 do not share: on perl 5.10.1 that raised peak RSS
from 205 MB to 303 MB for a 100 MB `stdout`.
[Changed]
- **A caller whose `STDOUT` is an in-memory handle now has the command's
output captured.** `Capture::Tiny` redirected the `STDOUT` glob, and a
glob opened on a scalar has no descriptor, so the command wrote past it
onto the real descriptor 1 — the terminal — and the record's `stdout`
came back empty. The redirect is now on the descriptor, which is what
the command inherits. A caller that relied on that output reaching the
terminal will now find it in `stdout` instead.
[Packaging]
- **`Capture::Tiny` is no longer a prerequisite at all.** The module
captures on its own descriptors, and the tests use `t/lib/CaptureStd.pm`,
a 46-line `capture {}` with the same calling convention that reopens
the `STDOUT` and `STDERR` globs — deliberately not the module's own
mechanism, so a bug in one cannot hide the same bug in the other.
`File::Temp`, which is core, is now a runtime prerequisite.
[Tests]
- **`t/03.fixes.t`**, one block per defect above except the race, and one
for the in-memory `STDOUT`, each confirmed to fail against 0.17 first;
and two covering the new capture's handling of closed descriptors and of
output layers, which pass against 0.17 as well, since the point is that
nothing changed there.
- Block 14 of `t/02.fixes.t` (`quiet => 1`) used to assert that the
command's output escaped an in-memory `STDOUT`; it now asserts that the
output is in the record.
0.17 2026-09-14 (Claude Opus 5 helped)
[Fixed]
- **A command that prompted hung for ever.** `Capture::Tiny::capture`
redirects file descriptors 1 and 2 and nothing else, so the command
inherited the caller's descriptor 0. A command that stops to ask a
question — `rm` over a write-protected file, `cp -i`, `git` asking for
credentials — wrote its prompt into the captured stderr, where nobody
could see it, and then blocked on the terminal waiting for an answer the
user did not know was wanted. Nothing was printed and, with `timeout` at
its default of 0, nothing ever returned; with a `timeout` set the process
group was killed and the record then said `timed.out => 1, signal => 9`,
blaming the clock for what was really an unanswered question. The command
now runs with descriptor 0 on the null device. Both execution paths were
affected and both are fixed: the `timeout` path forks and execs, and its
child inherited descriptor 0 across the fork just as `system()`'s did.
Found while debugging a pipeline that hung on `rm -r` over a read-only
file.
[Added]
- **`stdin`**: `'devnull'` (the default) or `'inherit'`, saying what the
command sees on its standard input. `'inherit'` restores the behaviour of
0.162 and earlier for a step that really does read the data the calling
script was given, with the hazards that implies: it consumes input the
caller can then no longer read, and a command that prompts hangs exactly
as it used to. The caller's standard input is saved and restored around
every run either way — including when the run dies, so a caller that traps
the exception is not left without it — and a caller that had closed it
keeps it closed.
[Changed]
- The result record carries `stdin`, the resolved value of that option.
- `File::Spec` (core) is now a dependency, for the name of the null device:
`/dev/null` on Unix, `nul` on Windows.
- Callers relying on the old behaviour are affected: a command that read the
calling script's standard input now reads end-of-file instead, and
succeeds while doing nothing. `stdin => 'inherit'` is the one-word repair.
[Packaging]
- **`SECURITY.md`**, giving an address to report a vulnerability to privately
and saying what is in scope. SimpleFlow runs the command it is given, so a
`cmd` string built out of untrusted data is a shell injection in the
*calling* program; the array-ref form of `cmd` runs without a shell and is
the way to avoid that.
- **`CONTRIBUTING.md`** now ships too. Both files are gathered by `[@Basic]`
without a `dist.ini` entry, and both are what the CPANTS experimental
metrics `has_security_doc`, `security_doc_contains_contact` and
`has_contributing_doc` look for. Checked by running the contact half of
`Module::CPANTS::SiteKwalitee::Security` over the built tarball: the
address it extracts is `dec986@gmail.com`.
- **`autodie` is no longer a prerequisite.** The only file that ever loaded it
is `md2pod.pl`, which `MANIFEST.SKIP` keeps out of the distribution, so
every installer was being asked for a module the shipped code never loads.
`Exporter` is declared instead, since the module does load it.
- **The test-only prerequisites are declared as such.** `Test::More`,
`Test::Exception` and `File::Temp` are used by `t/` and by nothing that is
installed, so they moved from `requires` to `test_requires`. `Test::More`
is pinned at 0.96 for the first time: every test file uses `subtest`,
which arrived in Test::Simple 0.94, and perl 5.10.1 shipped 0.92 — a
smoker with nothing beyond core could not have run the suite at all, and
nothing said so. The `Makefile.PL` folds `TEST_REQUIRES` back into
`PREREQ_PM` on ExtUtils::MakeMaker older than 6.63_03, so 5.10's own
toolchain still sees them.
- **`cover_db/`, `cover.sh` and `dzil.sh` no longer ship.** The committed
Devel::Cover report is stale by design — it predates `t/02.fixes.t` — and
was 39 files of HTML in the tarball; the two scripts are author-only, like
`md2pod.pl`. The distribution is 17 files, and passes its own suite (60
tests) when the tests are run inside the built tree.
0.162 2026-09-12 (Claude Opus 5 helped)
[Tests]
- **Block 14 of `t/02.fixes.t` failed on Data::Printer before 1.x.** A CPAN
tester on perl 5.20.0 with Data::Printer 0.38 reported it against 0.161.
The block redirects STDOUT to an in-memory handle to check that
`quiet => 1` silences the terminal, and asserted that exactly the
command's own output -- and nothing else -- escaped that redirect to the
real file descriptor 1. On Data::Printer 0.38 the record escapes too:
`use DDP {output => 'STDOUT'}` binds the handle as the property is
parsed, at import, while 1.002001 resolves it at print time, so a later
`local *STDOUT` cannot reach the older release. The block now counts how
many times the command ran instead of demanding that nothing else
escaped, and its command prints an upper-cased sentinel so that the
record, which quotes the command it ran, cannot be counted as a second
copy. Confirmed against Data::Printer 0.38 and 1.002001, on perl 5.10.1,
5.12.5 and 5.44.0. No module behaviour changed, and nothing about
`quiet` was wrong: on every version the record goes to the terminal
unless `quiet => 1` and to the log either way.
0.161 2026-09-07 (Claude Opus 5 helped)
[Fixed]
- **The printed record's length cap did nothing on Data::Printer before
0.99_001.** 0.16 capped each field of the record at 4096 characters by
handing `string_max` to Data::Printer and leaving the clipping to it, but
that property only arrived in Data::Printer 0.99_001 (2018-04-21) and
every earlier release ignores a property it does not know, in silence. A
CPAN tester on perl 5.20.0 with Data::Printer 0.38 therefore had a
200,000-character capture printed whole: 200,927 bytes to the terminal and
200,914 bytes to the log. SimpleFlow now clips the strings itself before
printing them, and marks what it dropped in Data::Printer's own wording,
so the ceiling holds on every version. The full capture is still on the
result hash, and the output on Data::Printer 1.x is unchanged.
[Tests]
- Added a regression test for the above (block 17 of `t/02.fixes.t`), confirmed
to fail against 0.16 first. Its probe loads a stub `DDP` that ignores every
property it is handed, which is what those Data::Printer releases did, and
is the only way to reproduce the flood on a machine whose Data::Printer is
current; it needs no network and nothing installed.
- Block 10 asked that `$VERSION` have exactly two decimal places, which this
release does not: 0.161 is a point release on 0.16 and keeps three. The
assertion now takes two or more, and a new one compares `$VERSION` against
the literal in the source digit for digit -- the check that actually
catches an unquoted version, since with the quotes off 0.161 the module
still reports "0.161" and any pattern on the digits passes.
- A passing run of the suite no longer looks like a crash. task() prints the
result record, and its failure paths dump the arguments with `p` and then
warn or die on the terminal -- all as documented -- so the three test
files together printed some 1,180 lines of record dumps and eight
backtraces on a clean, wholly successful run. Every call whose printing is
not itself under test now goes through a `quietly` helper that captures
both streams with `Capture::Tiny`, and the expected diagnostics are
asserted on rather than discarded: `prove -Ilib t/` prints TAP and nothing
else. Capturing was chosen over passing `quiet => 1` because it leaves the
arguments handed to task() unchanged, which is what keeps blocks 1-11 of
`t/02.fixes.t` runnable against 0.15 (re-checked: they still fail there).
No module behaviour changed.
[Documentation]
- README.md no longer carries the release notes, so they are no longer copied
into `read.me.pod` and the module's POD either. `Changes` is the only copy
now, and `md2pod.pl` checks it with `changes_file_ok()` rather than
generating it.
0.16 2026-08-28 (Claude Opus 5 helped)
[Fixed]
- **`die => 0` never reported a failure.** The `will.do => "FAILED"` assignment
sat inside the `if ($r{die})` branch, so it could only run on the path that
immediately died. Under `die => 0` — the mode in which the caller is meant
to read `will.do` — a command that exited non-zero was reported as `"done"`,
and nothing warned. `will.do` is now `"FAILED"` for a non-zero exit, a
timeout, or a missing output file regardless of `die`, and `die => 0` emits
a warning naming the exit code.
- **The log lost the record of the task that killed the run.** The log
filehandle was never autoflushed. Measured with a `SIGKILL` part-way through
a pipeline (the shape of an OOM kill or a scheduler eviction), a log holding
862 bytes on a clean exit held 139 bytes after the kill: everything written
after the last command started — its exit code, duration and captured output
— was still in stdio's buffer. `task` and `say2` now switch the handle to
autoflush.
- **An undefined filename still crashed.** 0.14 added a `defined` guard to the
0-length check, but the `-f -r` filetest ran first, so an `undef` element of
an `input.files` array died as `Use of uninitialized value $_ in -r` under
`warnings FATAL => 'all'`. Names are now validated before anything is
filetested.
- **The 0-length `input.files` check was unreachable.** `''` fails `-f`, so an
empty input filename was reported as `"missing or unreadable"` and the
0-length check below it could never fire. Both undefined and 0-length names
are now reported as what they are, and the message names the offending index.
- **`cmd` was not type-checked.** Only definedness was checked, so any reference
was stringified straight into the shell: `task(cmd => ['echo','hi'])` ran the
literal command `ARRAY(0x5ed9d076e618)`. `cmd` must now be a non-empty string
or a non-empty array ref of defined values.
- **Skip detection and the post-run check disagreed.** Skipping tested a bare
`-f` while the post-run check tested `-f -r`, so an output file that existed
but could not be read counted as already done. Both use `-f -r` now.
- **The result record changed shape between paths.** `exit`, `signal`, `stdout`
and `stderr` were absent after a skip or a dry run, so a caller running under
the `warnings FATAL => 'all'` this module recommends died just by reading
`$t->{'exit'}`. They are now always present, holding their empty values.
- **`string_max` was uncapped**, so a chatty command had its whole capture echoed
to the terminal and written to the log — a measured 3 MB stdout wrote
3,002,832 bytes to each. It is now capped at 4096 characters; Data::Printer
marks what it drops. The full capture is still on the result hash.
- **Loading SimpleFlow polluted `main::`.** `use DDP` and `use Cwd 'getcwd'` sat
above the `package` statement, so `p`, `np` and `getcwd` were imported into
every program that loaded the module. The `package` statement now comes
first, and the duplicated `use` lines are gone.
- **Unbalanced parenthesis** in the 0-length `output.files` error message.
[Added]
- **`stale`**: also re-run when an input file is newer than an output file, the
rule `make` and `snakemake` use. Off by default, so existing pipelines are
unaffected. The result carries `out.of.date`.
- **`timeout`**: a wall-clock budget in whole seconds. The command runs in its
own process group and the whole group is killed if the budget is exceeded,
so a wedged pipeline does not leave orphans behind. The result carries
`timed.out`. POSIX only.
- **An array-ref `cmd`** runs the command without a shell, so arguments coming
from data need no quoting.
- **`quiet`**: suppress the record printed to the terminal without silencing the
log or `STDERR`.
- **`input.file`**, the single-file convenience form of `input.files`, matching
`output.file`.
[Changed]
- `$VERSION` is now a quoted string. As a bare number it was stringified through
`%g`, so a future `0.20` would have become `"0.2"` and compared as older than
`"0.15"` on CPAN.
- **Incompatible:** `input.files` on the result is now always an array ref, as
`output.files` always was. A scalar argument used to be stored raw.
- `POSIX` (core) is now a dependency, for `_exit` in the timeout child.
0.15 2026-07-17 (Claude Opus 4.8 helped)
- addition of `output.file`, a single-file convenience form of `output.files`. It
takes one plain filename, cannot be combined with `output.files`, and dies if
given a reference or an empty name.
- removal of Term::ANSIColor dependency
- improved coverage testing
0.14 2026-06-29 (Claude Opus 4.8 helped)
[`task`]
- **New:** accepts a flat key/value list as well as a hash ref —
`task(cmd => ...)` and `task( cmd => ... )` are now equivalent. A lone
non-hashref scalar or any odd-length argument list is fatal.
- **Bug fix:** the default `die => 1` was ignored when checking for missing
`output.files`. The block tested the raw `$args->{'die'}` (undef when the
caller omitted it) instead of the resolved `$r{'die'}`, so a command that
failed to produce its declared outputs only warned instead of dying. Now
consistent with the exit-code check.
- **Bug fix:** removed a stray `)` (and an extraneous leading space) from the
"command is" line written to the log file; it now matches the on-screen form.
- **Bug fix:** `length $_ == 0` could throw a fatal uninitialized-value warning
(under `warnings FATAL => 'all'`) on an undef element of the `input.files`
array branch and the `output.files` empty-name check. Both now guard with
`(defined $_) && (length $_ == 0)`, matching the `input.files` scalar branch.
0.13 2026-06-11
[Fixed (Claude Opus 4.8 helped)]
- **Exit status and signal are now decoded correctly.** `task()` previously
computed the exit code (`$status >> 8`) and *then* derived the signal as
`$exit & 127`. Because the signal lives in the low byte of the raw wait
status, which `>> 8` discards the `signal` field was always wrong: a clean
`exit 42` was reported as `signal 42`, and a process actually killed by a
signal reported `signal 0`. The signal is now read from the raw status before
shifting, so `exit` and `signal` are independent and accurate.
- **No longer dies on a missing output file when `die => 0`.** The zero-size
check did `(-s $file) == 0`, which is `undef == 0` when a declared output file
is absent. Under `use warnings FATAL => 'all'` that "uninitialized value"
warning was fatal, so a task that was meant to *warn* about missing output
(with `die => 0`) crashed instead. Missing sizes are now treated as `0`, so
the task warns and returns its result hash as intended.
- **The "already done" result is now logged with its `duration`.** In the
short-circuit path (output files already exist), `duration` was set *after*
the record was written to the log, so the logged hash was missing it; the
duplicate `done => 'before'` assignment was also removed.
[Changed / Windows support]
- **Portable exit-status handling.** Decoding now branches on `$^O`: Windows has
no POSIX signals (`signal` is reported as `0` there), and a `system()` that
fails to launch the command (`-1`) yields `exit => -1` instead of a garbage
value from shifting `-1`.
- **ANSI colour is disabled on the legacy Windows console.** `Term::ANSIColor`
output is suppressed on `MSWin32` unless an ANSI-capable terminal is detected
(Windows Terminal, ConEmu, or ANSICON), so `cmd.exe` no longer prints raw
escape sequences and redirected logs stay clean. Unix and modern Windows
terminals are unaffected.
[Tests]
- Rewrote `t/01.t` to be cross-platform: shell commands now invoke the running
Perl interpreter (`"$^X" -e ...`) instead of Unix-only tools (`which`, `ls`,
`ln`, `cp`), and temp files use the system temp directory instead of a
hard-coded `/tmp`.
- Added regression tests for both fixed bugs (exit/signal decoding; surviving a
missing output file with `die => 0`).
- Added coverage for the `note` field, the `input.file.size` / `output.file.size`
hashes, scalar-vs-array normalisation of `input.files` / `output.files`, the
`dir` / `source.file` / `source.line` metadata, captured `stdout` / `stderr`
(including trailing-whitespace stripping), and argument validation (missing
`cmd`, unknown keys, bad `log.fh`, missing input files).
0.12 2026-02-14
- exit code now matches what shell would show it as; signal now appears
0.11 2026-01-13
- max string length now corresponds to max of output strings, no more truncated output
added List::Util dependency for string length maxes
memory size now shows when output
directory is now output during dry runs
Keyboard Shortcuts
Global
s
Focus search bar
?
Bring up this help dialog
GitHub
gp
Go to pull requests
gi
Go to GitHub issues (only if GitHub is preferred repository)