deployment - mvx-lang/mvx GitHub Wiki

Deployment

Single host: embedded

The default. Each account directory carries its own local database — mvxdata.sqlite, or mvxdata.lmdb for an lmdb account; concurrent sessions on one host share it safely. Directory files are plain directories. Nothing to operate.

A local database can live outside the account, which is what a container needs when the image is read-only and the data belongs on a mounted volume. That is a connection profile — see Named connections below.

More than one host

An embedded database must never live on a network filesystem, so an account reached from several hosts puts its files on a server built for it: postgres, or mysql or mongo.

> SET-CONNECTION salesdb driver=postgres address=mvxdb:5432 dbname=mvx user=app
> CREATE-FILE ORDERS USING @salesdb

The committed BINDINGS record names only the profile (ORDERS @salesdb), so it carries no address and no password; the profile is local and private.

Moving an existing file is CONVERT-FILE — records, dictionary and all — and a whole account is CONVERT-ACCOUNT:

> CONVERT-FILE ORDERS postgres
> CONVERT-ACCOUNT postgres

There used to be a daemon of our own. mvx-lmdbd, reached through an lmdbnet driver, served LMDB over a socket. Both were removed (mvx#327) after a deprecation in v0.6.0. The reason was pushdown: lmdbnet supplied none of select_where, select_order, count_where, sum_where, join or explain, so every WITH streamed the whole id list to the verb and filtered it there — and unlike embedded lmdb it paid network latency to do it. postgres answers those in SQL, holds record locks across sessions, and is not ours to maintain.

If you have an account on it, the last release that can read it is v0.6.0. Move the files from there with CONVERT-ACCOUNT postgres CONNECTION @salesdb before upgrading. $MVXDAEMON is no longer read: an account that still sets it is simply unbound and takes the account's default.

Choosing a backend per file

A file's backend is named at creation:

> CREATE-FILE ORDERS                          the account's default (sqlite)
> CREATE-FILE ARCHIVE DIR                      directory (git-native source)
> CREATE-FILE SHARED USING postgres            a shared SQL database
> CREATE-FILE LOCAL USING lmdb                 a local key/value store
> CREATE-FILE ORDERS USING @salesdb            a named connection (below)

USING <driver> {connection} binds the file to a storage driver — sqlite, lmdb, dir, postgres, mysql or mongo — recording it in the account's BINDINGS record and creating the file through that driver. DELETE-FILE removes the file and its binding; LISTF shows each file's backend by name. Different files may use different drivers and connections, so one account can mix a local database, files on several servers and directory files — and one program reads them all through ordinary OPEN/READ/WRITE.

The binding is a plain, hand-editable record: SPEC driver {params} per line (* as the spec binds every file; params is the driver's connection string, or @name for a profile). A line naming a file and no driver means the account's default. Binding is resolution only; existing data does not move when it changes.

To actually move a file to a different backend, use CONVERT-FILE:

> CONVERT-FILE ORDERS dir                         hash file -> directory file
> CONVERT-FILE ORDERS lmdb                        directory file -> hash file
> CONVERT-FILE ORDERS postgres                     local -> shared SQL

CONVERT-FILE file newtype {connection} re-keys the file's records — and its dictionary — into the new backend verbatim, so a hash file round-trips to a directory file and back without loss, and the %FILE% control record is restamped to the new type.

A whole account at once: CONVERT-ACCOUNT

Moving every file one CONVERT-FILE at a time invites missing one, which is a poor way to retire a backend. CONVERT-ACCOUNT does the account:

> CONVERT-ACCOUNT sqlite                       everything not already there
> CONVERT-ACCOUNT postgres FROM lmdb          only what is on one backend
> CONVERT-ACCOUNT postgres CONNECTION @sales   with a connection profile
> CONVERT-ACCOUNT sqlite LISTONLY              print the plan, change nothing

FROM is the retirement case — move what is on the backend being retired, leave the rest alone. LISTONLY prints the plan and touches nothing, which is how to find out what a conversion would do rather than discovering it afterwards.

Three rules are worth knowing before you run it:

  • A directory file is skipped unless you write FROM dir. The point of a dir file is that its records are OS files a person can read and git can diff; converting one to a hash backend destroys exactly the property it was created for. So the obvious command leaves your BP alone.
  • CATALOG is never converted, not even by FROM dir. The shell reaches a cataloged verb by path — a VOC record says CATALOG/CREATE-FILE and the shell execs it — so converting it would stop every verb in the account from running.
  • VOC goes last. Resolution starts there, so it is the one file whose move changes where everything else is looked up.

There is no rollback across an account. Each CONVERT-FILE is individually atomic — it stages into a temp file, then replaces — so a failure part-way leaves the earlier files converted and the later ones untouched, and the report names what did not make it. If the first file fails, nothing has been converted and the run stops there rather than repeating the same error for every file: a wrong target fails the same way for all of them.

What the daemon guarantees:

  • Single lock authority. READU locks are granted by the daemon and leased to the connection: a client that dies without RELEASE loses its locks the moment the connection drops. No orphaned locks from killed pods.
  • Index atomicity. A record write and its index updates commit in one daemon-side transaction.
  • Snapshot selects. SELECT materialises the id list before sending; a slow client never pins a read transaction.

Costs, accepted deliberately: the daemon is a single point of failure, the single-writer throughput ceiling remains, and replication/HA is not provided. Protocol integers are host-order — keep daemon and clients on the same architecture for now.

Several accounts on one server, isolated by namespace

A profile's namespace is what keeps two accounts on one database apart. Each backend spends it as its own native partition: postgres as the schema, mongo as the database. Same-named files in different namespaces are fully isolated — two accounts can each have their own private ORDERS.

  • It defaults to the account's own name (the basename of the account directory), so pointing two accounts at one server isolates them with no configuration at all.

  • Sharing is explicit — the Q-pointer analog. Name the home namespace in the profile, and any account that names SALES reads SALES's data:

    > SET-CONNECTION sales driver=postgres address=mvxdb:5432 dbname=mvx namespace=SALES
    > CREATE-FILE ORDERS USING @sales
    
  • Dictionaries are per-account views. A shared data file's dictionary (and its %FILE%/%INDEXES% control records) lives with the account that binds it, so different accounts can hold different column definitions over the same shared data. Index data is shared; index definitions are local.

  • Locking is the server's. A READU on a postgres file takes a session-level advisory lock, so it is honoured across processes and released when the session ends.

  • No cross-server atomicity. An account can bind files on several servers, but a unit of work spanning two of them cannot be one transaction.

Authentication is the server's too: a postgres role and password, a mongo user. The password belongs in the credential store or the environment, never in BINDINGS — see Credentials and secrets below.

Named connections

Rather than write the daemon host into every file's binding, define a named connection once and reference it. The committed BINDINGS names only the connection; the deployment-specific host, namespace, and token live in the local (git-ignored) .mvx-private/connections:

> SET-CONNECTION salesdb driver=postgres address=mvxdb:5432 dbname=mvx user=app password=<pw> namespace=SALES
> CREATE-FILE ORDERS USING @salesdb
> CREATE-FILE CUSTOMERS USING @salesdb
BINDINGS:  ORDERS     @salesdb
           CUSTOMERS  @salesdb

Move the database to a new host and you change one field — SET-CONNECTION salesdb address=newhost:5432 — with no change to BINDINGS and nothing to re-commit. The same profile carries the password, so a connection is the single place a file's whole remote binding lives; LIST-CONNECTIONS shows the profiles with secret fields masked. It is also how a container injects a target — set MVXCONN_SALESDB_ADDRESS and MVXCONN_SALESDB_PASSWORD and no file is needed.

The local backends read a profile too (mvx#326), which is how a sqlite or lmdb database lives outside the account — on a mounted volume, where a read-only image needs it. The inline form (CREATE-FILE … USING postgres <addr>) still works for a one-off.

Postgres

A file can live on a PostgreSQL database instead of LMDB. Each account/namespace is a schema; each file is a table (id text, doc jsonb) — the record is a JSON document, one field per attribute, and the id is text you can read. Records round-trip byte-exact, value marks and all. Define a postgres connection and bind through it:

> SET-CONNECTION pgmain driver=postgres address=db:5432 dbname=mvx user=app password=<pw> namespace=sales
> CREATE-FILE ORDERS USING @pgmain
> LIST ORDERS NAME PRICE

LIST, SELECT, SORT, and COUNT work as on any file. The driver is built only where libpq is present (it is skipped otherwise), and it authenticates with the credentials in the connection profile (.mvx-private), never in committed config.

The Postgres backend goes well beyond the minimal contract: mapped files push WITH/BY/FIRST queries down to SQL (see the mapping chapter), CREATE-INDEX builds native indexes, LISTF on an account bound entirely to Postgres (* @conn) enumerates the schema's record tables (DICT.<file> and association child tables excluded), and record locks are arbitrated across processes — a READU takes a Postgres session-level advisory lock keyed by the file and record id, so a READU from one process blocks (or, with LOCKED, reports busy to) another process on the same PG-backed file, and the lock releases on WRITE / RELEASE or when the holding session ends. No extra tables or configuration: the advisory locks live in Postgres itself. The advisory lock is taken for native-mapped and unmapped files (where the backend holds the authoritative data); a mirror-mapped file, whose SQL is only a derived projection of an authoritative record blob, uses the process-local lock table instead.

MySQL and MariaDB

A file can live on MySQL or MariaDB through the mysql driver — one table per file, (id, doc), the record as a JSON document. Both servers are supported and the driver avoids MySQL-only syntax where it can: attribute expressions use JSON_UNQUOTE(JSON_EXTRACT(...)) rather than the ->> shorthand, which MariaDB does not accept.

> SET-CONNECTION mydb driver=mysql address=db:3306 dbname=mvx user=app password=<pw>
> CREATE-FILE ORDERS USING @mydb

One difference between the two, and it is about indexes. Indexing an un-mapped attribute needs a multi-valued index — MySQL 8.0.17 and later — because the push-down asks whether any one of the attribute's values matches, and a multi-valued index is the only kind that question can use. MariaDB does not implement multi-valued indexes at all, so there:

> CREATE-INDEX ORDERS CITY
ORDERS.CITY cannot be indexed by this backend
the query still works -- the filter runs in the backend or the
verb, unindexed, so only speed is lost

Nothing silently changes meaning: the WITH is still pushed into MariaDB as JSON_CONTAINS(JSON_EXTRACT(...)) and still answers correctly, including for multivalued attributes. Only the index is absent, so the server scans.

The workaround is to map the field first, which gives it a real column that both servers index normally:

> CREATE-MAP ORDERS CITY
> CREATE-INDEX ORDERS CITY
index ORDERS.CITY built, 2 record(s)
MySQL 8 MariaDB
mapped column indexed indexed
un-mapped attribute indexed (multi-valued) refused; pushed down and scanned

The driver is built only where a MySQL/MariaDB client library is present and is skipped otherwise.

MongoDB

A file can also live on MongoDB. Each account/namespace is a database; each file is a collection; and each record is a document { _id: "<id>", doc: { … } } — a string id you can read, and the record as a BSON sub-document. Records round-trip byte-exact, value marks and all. Define a mongo connection and bind through it:

> SET-CONNECTION mongomain driver=mongo address=db:27017 namespace=sales user=app password=<pw>
> CREATE-FILE ORDERS USING @mongomain
> LIST ORDERS NAME PRICE

address is host:port (or set a full mongodb://… URI as the connection instead of @name); credentials come from the connection profile (.mvx-private), never committed config. The driver is built only where libmongoc (the mongo-c-driver, v1 or v2) is present, and is skipped otherwise.

Relational mapping, indexes, and push-down

CREATE-MAP (see Relational mapping) works against Mongo in mirror mode: the record blob stays authoritative, and each mapped dictionary column is projected onto the document as a native BSON field — scalar columns as top-level fields, and an association as an embedded array of sub-documents (the natural Mongo shape). Given a MORD file mapped on NAME, STATE, PRICE, and an association LINES (PRODUCT, QTY), a record stores as:

{ _id: "O1", rec: <blob>,
  NAME: "Widget", STATE: "NSW", PRICE: 9.99,
  LINES: [ { PRODUCT: "A", QTY: 2 }, { PRODUCT: "B", QTY: 3 } ] }

NUMERIC columns store as BSON numbers (so they sort, sum, and display as numbers). With the projection in place:

  • an equality WITH filter (= / #) on a mapped column and a filtered COUNT run server-side ($in/$nin, so the record need not stream to the verb);
  • CREATE-INDEX on a mapped column builds a real Mongo index (createIndexes), and indexed lookups read from it.

The rec blob is updated in place ($set) rather than replaced, so mapped fields survive a write that did not change them. Still evaluated in the verb by scanning (client-side): native read-back (mirror mode only), SUM / BY / multi-condition push-down, raw-attribute filters (Mongo cannot split the blob server-side), TRANS() joins, DESCRIBE, and cross-process locks — see #62.

Credentials and secrets (.mvx-private)

Backends that authenticate — a networked-LMDB namespace token, a Postgres username and password — need a secret to connect. Those secrets must not live in git-committed account config (BINDINGS, .mvx, the VOC): a clone or BUILD should provision an account without carrying its credentials, with the operator supplying them out-of-band.

So secrets live in a per-account directory .mvx-private/, a single credentials file, netrc/pgpass-style — created 0700/0600, and git-ignored. BINDINGS names only the non-secret reference (driver, target, key); the runtime resolves the secret from the store.

> SET-CREDENTIAL mongo mvxdb-a:27017 SALES token=abc123
> SET-CREDENTIAL postgres db:5432 mvx user=app password=s3cret
> LIST-CREDENTIALS                       values are masked

.mvx-private/credentials then holds one field per line — the first three whitespace tokens are driver target key, and the rest of the line is field=value, where the value runs to end-of-line and may contain spaces and other characters (so an arbitrary secret survives verbatim):

mongo    mvxdb-a:27017 SALES  token=abc123
postgres db:5432       mvx    user=app
postgres db:5432       mvx    password=p@ss w0rd :/@

An environment variable overrides the file so a container can inject a secret without writing one — MVXCRED_<DRIVER>_<KEY>_<FIELD>, upper-cased with non-alphanumerics as _ (e.g. MVXCRED_LMDBNET_SALES_TOKEN). Resolution is env override, then the file, then a clear error.

Deploying secrets from CI / GitHub Actions

The store is plain text with an end-of-line value, so a deploy job can pull secrets from GitHub Actions secrets and write them out. Two patterns:

Whole file as one secret — keep the entire credentials file content in a single repository/environment secret and write it with a tight umask:

- name: Write credentials
  run: |
    mkdir -p account/.mvx-private
    ( umask 077; printf '%s' "${{ secrets.MVX_CREDENTIALS }}" \
        > account/.mvx-private/credentials )

Per-field env vars — map individual secrets to the override variables and write no file at all:

env:
  MVXCRED_POSTGRES_MVX_PASSWORD: ${{ secrets.PG_PASSWORD }}
  MVXCRED_LMDBNET_SALES_TOKEN:   ${{ secrets.SALES_TOKEN }}

Either way the secret never lands in git; .mvx-private/ stays local to the deployed account.

Keep .mvx-private/ out of version control — it is a dotfile, so MVX's own account export already skips it; for an account kept in a plain git repo, add it to .gitignore alongside mvxdata.lmdb/. The consuming backends (networked-LMDB tokens, the Postgres driver) are tracked in #7, #9, and #11.

Committing and cloning an account through git

Hash files are binary, so an account is never committed as-is. Git tracks the directory form — each file as NAME/ (one text file per record) beside NAME.DICT/ (its dictionary) — while a live account is hash files with no .DICT.

mvx-git-adopt turns the first into the second:

mvx-git-adopt <account>        directory form -> live hash files

It goes one way only. Every file is rebuilt as the backend its dictionary's %FILE% control record names — a hash file for an lmdb file, a directory file (keeping its NAME.DICT) for a dir file — then BUILD catalogs BP and links packages. %FILE% is the file definition, so a committed dictionary alone recreates its file.

There is no export tool and no --export flag. The other direction is not a conversion step at all: mvx-git writes the directory form itself, from the live records, as part of add/commit (see Version Control).

You rarely call mvx-git-adopt directly — mvx-git clone adopts for you when the clone carries a .mvx descriptor. Run it by hand only for a plain git checkout — the bootstrap that turns mvx-lang's own system account and packages into real accounts before mvx-git exists.

This is the stock-to-site delivery path — combined with record-level branch, merge, and cherry-pick (see Version Control), a customised site tracks stock upstream and feeds features back.

Record ids in the database

An id is a byte string. Usually it is a key somebody typed; occasionally it has a mark or a raw byte in it. It is stored as text, so a record reads as itself in the tools a DBA already uses:

sales=> SELECT id FROM "ORDERS" ORDER BY id;
      id
--------------
 50%25 OFF
 A%FDB
 Café
 INV-2026-001

Only the bytes the database's own character set cannot carry are escaped. On a UTF-8 database Café is stored as itself and only a mark byte becomes %FD; on a LATIN1 or SQL_ASCII one almost nothing is escaped at all. Two bytes are escaped everywhere — % (written %25, which is what makes decoding unambiguous without a marker) and NUL — and one in one position: a trailing space, written %20, because a PAD SPACE collation would otherwise read A and A as the same key.

The character set is a property of the stored data, never of your session: Postgres asks server_encoding and MySQL the database charset, and each pins its session to match so nothing is transcoded on the way in or out. It is recorded beside the format —

sales=> SELECT obj_description('"ORDERS"'::regclass);
 mvx: format=3 idcs=utf8 idenc=UTF8

— and a file whose stamp disagrees with the database it is now in is refused by name rather than half-read. That matters after a dump is restored into a differently encoded database: the text is re-spelled by the restore, so every key built afterwards would silently miss. (SQLite records the format in PRAGMA user_version; Mongo needs neither, because a BSON string is UTF-8 by definition.)

Decoding never depends on any of this — it is one operation, forever — so an id written under any character set reads back byte-exact.

Converting a database from an earlier release

mvx-doc-migrate brings every file in a location up to the current format, in place, and is safe to re-run:

$ mvx-doc-migrate sqlite /srv/acct/acct.sqlite
$ mvx-doc-migrate mysql 'host=db port=3306 user=app password=… dbname=sales'
$ (cd /srv/acct && mvx-doc-migrate postgres @pgmain)
$ (cd /srv/acct && mvx-doc-migrate mongo @mongomain)

It does two conversions: a pre-#157 record blob becomes a JSON document, and a binary record id becomes text. It also re-spells ids when the character set has changed under them, recovering the original bytes through the database's own inverse conversion (convert_to in Postgres, CONVERT … USING in MySQL) rather than guessing. Each table is judged by its own stamp, so a file already spelled for this database is left alone. A conversion that cannot be completed writes nothing and says which file and why.

The migration curve

embedded LMDB  ->  networked LMDB  ->  (heavier backends)

Each step is configuration, not surgery, because the driver contract is the boundary. Migration can be per file: a directory file, an embedded file and a daemon file coexist in one account.

Privilege and containers

$MVXPRIV comes from the process environment — system-level configuration that account data cannot write. In containers, run app sessions stateless with the account's data on a volume or on a shared server, and grant unrestricted only where a shell escape is truly needed.

Sessions and ports (mvx-msgd)

MVX can keep a roster of who is logged on. It is optional: with no registry running, everything behaves exactly as it did before — WHO prints the user and the account, @USERNO is 0, and no program is affected.

mvx-msgd -s /run/mvx/msgd.sock           # the registry, one per host

Sessions find it through $MVXMSGD (default /tmp/mvx-msgd.sock). Each session that starts takes the lowest free port number, and LISTU shows the roster:

> LISTU
Port User            Account          Logged on  Line
   1 fred            SALES            09:14:22   /dev/ttys004
   2 jane            SALES            09:31:05   /dev/ttys007

2 session(s) on prefix SALES

The connection is the lease. A session holds its socket open for as long as it lives, so a clean logoff, a killed process and a dropped SSH all remove it from the roster with nothing to clean up. There are no stale entries to sweep.

A port is scoped by a prefix, which defaults to the account name, so a user keeps the same address whichever host they log on to. Set it explicitly where several accounts should share one space, or where one should be split:

mvx-msgd -s /run/mvx/msgd.sock -x SALES -b 1

-b sets the lowest port number, and -x the prefix. A verb runs as a child of its shell and joins the port the shell already holds, so a command does not log a second session on.

Messages between ports

> MSG !2 the backup has finished
sent to 1 port
> MSG * system going down at 18:00
sent to 4 ports
> MSG SALES please log off
> MSG @fred your report is ready

A port that is not logged on receives nothing, as classic Pick did, and MSG says how many ports it reached. Sending to one port is ungated; * needs an msgwall permit and a whole account an msgbroadcast one, checked in the runtime rather than in the verb — anyone who can compile could otherwise call MSGSEND directly.

A message never writes on a program's screen. It waits in the session's inbox until the program asks for it, so a full-screen program keeps its form:

LOOP
   K = KEYIN(250)
   IF MSGPENDING() > 0 THEN GOSUB SHOWMSG   ;* paint it where you choose
   ...
REPEAT

examples/MSGDEMO.b is a working example. A program that never calls MSGPENDING never sees one, which is what you want for a report that runs for an hour, and MSGMODE("OFF") has the daemon discard them on arrival — except a wall or system message, which a user may not switch off.

At the TCL prompt a message appears as it arrives, even mid-command: the shell waits on the terminal with a deadline rather than blocking in a read, so an arriving message prints and the line you were typing is redrawn underneath it, untouched. There is no signal handler anywhere in this path — a deadline on a poll, not an interrupt.

Sending from BASIC

The intrinsics are in Intrinsics. SEND and MESSAGE are the statement spellings, for the same thing:

SEND "Backup starting" TO 7 THEN PRINT "sent" ELSE PRINT "port 7 is off"
MESSAGE OFF

SEND takes a port, as D3 spelled it, and takes THEN when the message reached somebody — a user, an account or a wall goes through MSGSEND. Neither word is reserved: MESSAGE is exactly the name a program would give a variable, so each is a statement only in a shape that cannot be anything else, and SEND = "x" still compiles.

The inbox holds 256 messages (64 KB) and drops the oldest, counting what it lost, so MSGDROPPED() can say so honestly. Sending is rate limited per port.

A message is an ordinary MV record, so it needs no decoding:

1 version   2 class + flags   3 from port   4 from user   5 from account
6 from host 7 sent            8 text        9 payload

Sending somebody a record

A message can carry an attachment: where to find a record, so the person receiving it can open it.

> MSG !7 WITH ORDERS O1234 have a look at this
sent to 1 port, with ORDERS O1234

MSG refuses to send a pointer to a record that is not there — otherwise the receiver finds out by trying to open it, long after you have gone.

On the receiving terminal:

[3] jane: have a look at this
     attached: ORDERS O1234 — on the stack as "CT ORDERS O1234" (.1 to recall)

The terminal offers; it does not open. The command goes on the stack and the record opens when you recall it and press return. A sender can hand you a screen, and cannot take yours.

Attribute 9 carries, by value mark:

1 file   2 record id   3 program   4 mode ("view" or "edit")

Positions 5 and beyond are free for an application, and a reader ignores what it does not recognise — which is what lets the convention grow without every reader being taught first. MODE picks the verb the shell offers (CT to look, ED to change); a named PROGRAM wins, because an application knows its own screens better than the shell does. A program builds one with MSGSEND:

PAY = "ORDERS":@VM:ID:@VM:"ORDER.ENTRY":@VM:"edit"
X = MSGSEND("@":WHO, "can you fix this one?", 0, PAY)

Locks are a separate problem. A screen written with edit and read-only modes should offer the view mode when the record is locked elsewhere; that is application behaviour, not something the runtime decides.

Messaging across hosts

A single host needs no service at all: mvx-msgd with its built-in loop transport is the whole system. To reach ports on other machines, point each host's daemon at a message broker:

mvx-msgd -s /run/mvx/msgd.sock -t mqtt -c tcp://broker:1883 -x SALES -b 1
  • -t names the transport. loop (the default) is one host and no service; mqtt is built when libmosquitto is present (MVX_WITH_MQTT).
  • -c is the broker. Credentials come from the daemon's environment — MVXMSG_USER and MVXMSG_PASSWORD — so they never reach a client process.
  • -x is the port prefix, shared by every host that should see one another.
  • -b is the lowest port number this host hands out. Give each host its own block (1, 201, 401), so two daemons never allocate the same number.

docker/compose.msg.yaml runs a broker and two application containers, each with its own daemon, as a working example.

The roster spans hosts. Each daemon announces its sessions on a retained topic and clears them when they go, so WHO and LISTU list every machine sharing the prefix, and MSG !201, MSG @fred and MSG SALES all resolve against the whole system and report a real count.

A daemon that dies cannot clear anything, so it registers a last will: an empty message on its own topic, which every other daemon reads as "forget what came from there". Its sessions leave the other rosters within seconds, without anyone waiting for a timeout.

Where a service offers neither retained messages nor a will, the daemon falls back: sessions are re-announced periodically and entries that go quiet expire. The roster is then a little slower to settle, and just as correct.

A broker that is down does not take messaging away. Sessions on the same host go on messaging each other, publishes are buffered and flushed on reconnect, and MSGSTATUS() reads degraded rather than up:

> MSG !2 still works
sent to 1 port

The transport is a driver, like a storage backend: libmvxmsg_mqtt is the only thing that links libmosquitto, and another service can be added without changing anything above the daemon.

Who is logged on, and what counts as one session

Every program is present, whether or not it ever sends a message. A report that runs for an hour shows up in WHO, and a message sent to its port has somewhere to go. Registering only programs that use messaging would make the roster a list of the programs that use messaging.

One person at one terminal is one port. A verb, a program run with EXECUTE, and a cataloged subroutine all join the session they were started from rather than taking a port of their own:

> PORTSHOW
port=1
execute=1
> LISTU
   1 fred      SALES     app1     09:14:22
1 session(s) on prefix SALES

A program that crashes returns you to TCL, still logged on. The shell holds the session; a program only attaches to it. The port, the roster entry and any queued messages survive. Only when the shell itself goes does the session end — and then it ends by itself, because the connection is the lease.

⚠️ **GitHub.com Fallback** ⚠️