Index

SSHJob

MMO\core\server · 1 symbol · generated 5 Oct 2026

class

SSHJob

SSHJob.php:60

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.

class MMO\core\server\SSHJob
Namespace
MMO\core\server
See
$id (SSH::getJob).
# 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

Index

FOLDER
ID_PATTERN
MARKER
command
endedAt
exitCode
folder readonly
id readonly
loaded
offsets
pgid
pid
remoteTime
sizes
ssh readonly
startedAt
state
buildWrapper($cwd, $env)Build the run.sh the remote setsid/nohup runs.
clean()Delete the job's remote folder, output included.
debugName()
getCommand()
getDuration()
getEndTime()
getExitCode()
getIncrementalOutput($stderr)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.
getOutput($stderr)Everything the command has written so far, from the beginning.
getOutputSize($stderr)
getPid()
getStartTime()
getState()
getStatus()The whole state in one array -- what a controller wants to hand to a UI polling for progress.
isDone()
isRunning()
isSuccessful()
kill($signal)Send one signal to the job, and nothing else -- stop is the one to use to actually end it.
load()Refresh once, if it never happened.
NewId() static
Number($value) static
path($file)
refresh()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.
rewindOutput()Rewind the getIncrementalOutput cursors, so the next call returns the output from the start again.
Start($ssh, $cmd, $cwd, $env, $id, $root) staticStart $cmd on $ssh's host, detached, and return the SSHJob following it.
stop($timeout, $signal)End the command: SIGTERM, then SIGKILL if it's still there $timeout seconds later (Process::stop does the same locally).
wait($timeout, $interval, $onOutput)Block until the command is over, polling the remote host every $interval seconds.
__construct($ssh, $id, $root)Bind to a job by id -- SSH::getJob is the normal way in, and Start the way to create a new one.

Constants3

FOLDER

const FOLDER = '/tmp/.mmo-jobs'

ID_PATTERN

const ID_PATTERN = '/^[A-Za-z0-9._-]{1,64}$/'

MARKER

const MARKER = 'MMO_JOB_STARTED:'

Properties14

command

protected ?string $command = null
Type
null | string

endedAt

protected ?int $endedAt = null
Type
null | int

exitCode

protected ?int $exitCode = null
Type
null | int

folder

readonly
readonly string $folder
Type
string

id

readonly
readonly string $id
Type
string

loaded

protected bool $loaded = false
Type
bool

offsets

protected array $offsets = ['out' => 0, 'err' => 0]
Type
array

pgid

protected ?int $pgid = null
Type
null | int

pid

protected ?int $pid = null
Type
null | int

remoteTime

protected ?int $remoteTime = null
Type
null | int

sizes

protected array $sizes = ['out' => 0, 'err' => 0]
Type
array

ssh

readonly
readonly MMO\core\server\SSH $ssh
Type
SSH

startedAt

protected ?int $startedAt = null
Type
null | int

state

protected MMO\core\server\cJobState $state = MMO\core\server\cJobState::MISSING
Type
cJobState

Methods28

buildWrapper($cwd, $env)

SSHJob.php:215

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
$cwdnull | string
$envnull | array
returnsstring
throwsif an $env name isn't a valid shell identifier.

clean()

SSHJob.php:511

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.
returnsboolwhether the folder is gone -- false means the remote host refused to delete it (permissions).

debugName()

SSHJob.php:122
function debugName(): string
returnsstringe.g. 'job:deploy-4f2a@SSH://user@example.com:22'.

getCommand()

SSHJob.php:354
function getCommand(): ?string
returnsnull | stringthe command this job runs -- read back from the remote host when this instance didn't start it (SSH::getJob), null if the job folder is gone.

getDuration()

SSHJob.php:368
function getDuration(): ?int
returnsnull | inthow 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.

getEndTime()

SSHJob.php:380
function getEndTime(): ?int
returnsnull | intwhen the command finished, as an epoch on the remote clock, or null if it hasn't.

getIncrementalOutput($stderr)

SSHJob.php:410

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
$stderr = falseboolstderr instead of stdout.
returnsstring

getOutput($stderr)

SSHJob.php:396

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.

function getOutput(bool $stderr = false): string
$stderr = falseboolstderr instead of stdout.
returnsstring

getOutputSize($stderr)

SSHJob.php:386
function getOutputSize(bool $stderr = false): int
$stderr = falsebool@return int the size in bytes of the job's output, as of the last refresh -- a free progress indicator for a command that prints as it goes.
returnsint

getStartTime()

SSHJob.php:375
function getStartTime(): ?int
returnsnull | intwhen the command started, as an epoch on the remote clock, or null if unknown.

getStatus()

SSHJob.php:522

The whole state in one array -- what a controller wants to hand to a UI polling for progress.

function getStatus(): array
returnsarray{id, command, state, pid, exit, start, end, duration, out, err} (out/err are *sizes*).

kill($signal)

SSHJob.php:488

Send one signal to the job, and nothing else -- stop is the one to use to actually end it.

function kill(int $signal = 15): bool
$signal = 15inta signal *number* (15 SIGTERM, 9 SIGKILL, 1 SIGHUP, 2 SIGINT, ...).
returnsboolfalse if there is no pid to signal (cJobState::STARTING, or an already finished job).
throwsif $signal isn't a plausible signal number.

load()

SSHJob.php:312

Refresh once, if it never happened. @return static

protected function load(): static
returnsstatic

NewId()

staticSSHJob.php:193
static function NewId(): string
returnsstringa fresh job id: sortable (so ls lists jobs oldest first) and unique per host.

Number($value)

staticSSHJob.php:307
protected static function Number(string $value): ?int
$valuestring
returnsnull | int$value as an int, or null if it wasn't a number at all (an unwritten file reads as '').

path($file)

SSHJob.php:203
protected function path(string $file = ''): string
$file = ''stringa file inside the job folder, or none for the folder itself.
returnsstringthe absolute remote path, quoted for the remote shell (SSH::quote).

refresh()

SSHJob.php:256

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.

function refresh(): static
returnsstatic
throwsif the SSH session is unusable.

Start($ssh, $cmd, $cwd, $env, $id, $root)

staticSSHJob.php:148

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.
$sshSSH
$cmdstring
$cwd = nullnull | stringthe 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.
$env = nullnull | arrayenvironment 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.
$id = nullnull | stringthe job's id, defaults to a generated one. Must match ID_PATTERN.
$root = nullnull | string__construct.
returnsstatic
throwsif $id, $root, or an $env name is unusable, or if $cmd contains the delimiter line.
throwsif the remote host didn't confirm the launch (unwritable folder, no sh, ...).

stop($timeout, $signal)

SSHJob.php:466

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
$timeout = 10.0floatseconds to wait for a clean exit before SIGKILL.
$signal = nullnull | intthe 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.
returnsnull | intthe exit code if the command finished on its own before being killed, null otherwise.

wait($timeout, $interval, $onOutput)

SSHJob.php:436

Block until the command is over, polling the remote host every $interval seconds.

function wait( ?float $timeout = null, float $interval = 1.0, ?callable $onOutput = null ): ?int
$timeout = nullnull | floathow long to wait at most, in seconds, or null to wait forever.
$interval = 1.0floatseconds between two refresh calls -- each one is a full SSH round trip, so don't set this too low on a job expected to run for minutes.
$onOutput = nullnull | callablecalled as $onOutput(string $stdout, string $stderr, SSHJob $job) after every poll that produced new output (getIncrementalOutput), for progress reporting.
returnsnull | intthe exit code, or null if the job ended without one (cJobState::DEAD).
throwsif $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.

__construct($ssh, $id, $root)

SSHJob.php:105

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).
$sshSSH
$idstringmust match ID_PATTERN: it is interpolated into a remote path that clean eventually rm -rfs, so anything else is refused outright rather than escaped.
$root = nullnull | stringthe folder the job's own folder sits in, defaults to SSH::$jobsFolder.
throwsif $id or $root is unusable.