A command started on a remote host and *detached* from the SSH session that started it (SSH::execBackground): it keeps running after the command returns, after the SSH session is closed, and after this PHP process exits.
# Why not just [`SSH::openExec`](https://mcp.mmoframework.fr/documentation/mmo.core/php/server/SSH.md#ssh-openexec)
`openExec()` also returns immediately, but the command it starts lives inside the SSH channel: closing the
stream, disconnecting, or simply reaching the end of the PHP script kills it (SIGHUP), and its output only
exists as long as somebody keeps reading the stream. A job instead runs under `setsid` (or `nohup` when
`setsid` isn't installed) with all three standard descriptors redirected to files in its own remote folder,
so the only thing needed to follow it -- from a later request, another process, or another machine -- is its
# Remote layout
Everything lives in one folder per job (`<root>/<id>`, `chmod 700`), and every status query is a single
`sh` round trip over the existing SSH session:
| file | content |
|---|---|
| `cmd` | the command, verbatim, as written by [`Start`](https://mcp.mmoframework.fr/documentation/mmo.core/php/server/SSHJob.md#sshjob-start) (also what `run.sh` executes) |
| `run.sh` | the generated wrapper: writes `pid`/`pgid`/`start`, runs `cmd`, writes `end`/`exit` |
| `pid` | the wrapper's pid -- written by the wrapper itself (`$$`), atomically (write + `mv`) |
| `pgid` | the wrapper's process group, when `ps` is available: used by [`stop`](https://mcp.mmoframework.fr/documentation/mmo.core/php/server/SSHJob.md#sshjob-stop) to kill the whole tree |
| `start` / `end` | epoch seconds, as reported by the *remote* clock ([`getDuration`](https://mcp.mmoframework.fr/documentation/mmo.core/php/server/SSHJob.md#sshjob-getduration)) |
| `out` / `err` | the command's stdout / stderr |
| `exit` | the command's exit code, written atomically once it finished (its presence *is* "finished") |
$job = $ssh->execBackground('composer install --no-dev', '/var/www/app');
$id = $job->id; // store it somewhere (session, database, ...)
// ... a later request, a new PHP process, a new SSH connection:
$job = $ssh->getJob($id);
if ($job->isRunning()) {echo "still going, {$job->getDuration()}s in";}
elseif ($job->isSuccessful()) {echo $job->getOutput();}
else {echo "failed [{$job->getExitCode()}]: " . $job->getOutput(true);}
$job->clean(); // remove the remote folder once done with it
Only what the command wrote since the last call (or since the last getOutput) -- the way to follow a long job without re-downloading its whole output every time.
Build the run.sh the remote setsid/nohup runs. Everything it contains is either generated here or a value quoted for the remote shell -- the command itself is in a separate file (Start).
protected function buildWrapper(?string $cwd, ?array $env): string
Delete the job's remote folder, output included. Nothing about the job can be read afterwards: the next
function clean(): bool
See
refresh (which any getter triggers, since this resets what was already read) reports, cJobState::MISSING and forgets the exit code -- read whatever is worth keeping *before* cleaning. WARNING: does not stop a running job first (stop for that); it just takes away the files it writes to.
how many seconds the command ran (so far, if it's still running), or null if it never got as far as recording a start time. Measured entirely on the *remote* clock -- comparing a remote timestamp with the local one would silently report the two machines' clock offset instead of a duration.
Only what the command wrote since the last call (or since the last getOutput) -- the way to follow a long job without re-downloading its whole output every time.
function getIncrementalOutput(bool $stderr = false): string
Everything the command has written so far, from the beginning. Also moves the getIncrementalOutput cursor to the end of what it returned, so the two can be mixed without seeing the same bytes twice.
true while the command may still produce output: it either hasn't written its pid yet (cJobState::STARTING) or its wrapper is alive (cJobState::RUNNING).
true if the command finished with exit code 0 -- false while it's still running, and false for a job that was killed (cJobState::DEAD) or already cleaned (cJobState::MISSING).
Re-read the job's state from the remote host: one sh round trip over the existing SSH session, no matter how much the command has written. Every getter below calls this on first use, then serves what it read -- call it again to get fresh numbers.
Start $cmd on $ssh's host, detached, and return the SSHJob following it.
static function Start(
MMO\core\server\SSH $ssh,
string $cmd,
?string $cwd = null,
?array $env = null,
?string $id = null,
?string $root = null
): staticThe command text is sent to the remote host inside a quoted here-document and written to a file verbatim,
then run with `sh <file>`: it is never interpolated into the generated wrapper, so it needs no escaping and
can be as compound as you like (pipes, `&&`, quotes, several lines, its own `exit`).
NOTE: the one thing the command may not contain is a line consisting of exactly the here-document
delimiter, `__MMO_CMD_<id>__` -- checked, and refused, rather than silently truncated.
WARNING: like every other method here, `$cwd` and the *values* in `$env` are quoted for the remote shell
([`SSH::quote`](https://mcp.mmoframework.fr/documentation/mmo.core/php/server/SSH.md#ssh-quote)), but the command itself is a command: never build it from untrusted input.
the remote folder to run from -- the job fails with the shell's own cd error (exit code 2 on most shells, in err) if it doesn't exist, rather than running somewhere unexpected.
environment variables to export before running, as name => value. Set this way rather than through ssh2_exec's own $env argument, which sshd drops unless every name happens to be whitelisted in its AcceptEnv.
End the command: SIGTERM, then SIGKILL if it's still there $timeout seconds later (Process::stop does the same locally). Kills the whole process group when the remote host gave the job one (setsid + ps both available, the normal case), so a command that spawned children doesn't leave them behind.
function stop(float $timeout = 10.0, ?int $signal = null): ?int
the signal to escalate to, default SIGKILL (9). Signals are sent as numbers, not names: the local PHP may well have no pcntl (hence no SIGKILL constant), and the remote kill is a different implementation anyway.
called as $onOutput(string $stdout, string $stderr, SSHJob $job) after every poll that produced new output (getIncrementalOutput), for progress reporting.
the exit code, or null if the job ended without one (cJobState::DEAD).
throws
if $timeout elapsed and the command is still running -- the job itself is left alone (still running remotely): catch it and call stop to actually end it.
Bind to a job by id -- SSH::getJob is the normal way in, and Start the way to create a new one.
function __construct(MMO\core\server\SSH $ssh, string $id, ?string $root = null)Does NOT talk to the remote host: nothing is known about the job until [`refresh`](https://mcp.mmoframework.fr/documentation/mmo.core/php/server/SSHJob.md#sshjob-refresh) (or any getter, which
refreshes on first use).