CLI doctor verdict
This document specifies the machine-readable verdict the alcove doctor subcommands emit under --json. It covers the document's fields, the byte encoding of the line holding them, the schema version and the compatibility rule a consumer applies before reading anything else, the overall and per-check status vocabularies, the ordered check list each mode reports, the relay check's bound and outcomes, what the verdict withholds, and the exit code each verdict maps to. It is the spec-tier complement to the operator-facing Checking a network shared folder and Checking the relay of a WebRTC exchange sections in CLI.md, which say what the three modes check and how an operator runs them; this document says what a caller may rely on. It does not cover the SMB_* inputs the checks read (see CLI.md), the CLI-wide exit-code table these codes sit within (see CLI.md), or the rendezvous semantics the checks are chosen to exercise (see FILE_SYNC.md). Intended readers are implementors of a setup launcher or supervising process, and security auditors.
The verdict is a contract, not a formatted log: a launcher keys on the check ids and the overall value rather than parsing check lines, so a check keeps its id and a consumer reads version before anything else.
The verdict document
--json writes the verdict as exactly one line of JSON on stdout, and nothing else reaches stdout, so a capture or a pipe holds the verdict alone. It replaces the human check lines rather than accompanying them. Without --json no verdict document is produced at all: the check lines go to stderr, and the exit code states the classification. Those lines are a rendering for a person to read rather than log records, so they have none of the [timestamp] [level] [context] prefix the CLI puts ahead of a diagnostic, and a launcher that collects and re-prints them passes on what the operator would have seen. They are written to the same destination the diagnostics are, so --log-file captures them, and --log-level admits or suppresses them exactly as it does a diagnostic.
{"version":1,"mode":"probe","overall":"fix_and_retry","checks":[{"id":"tcp_445","status":"ok"},{"id":"write","status":"fail","meaning":"this account can read the folder but not write to it.","action":"ask whoever administers the share for write permission on this folder."}]}| Field | Type | Value | Meaning |
|---|---|---|---|
version | integer | 1 | Verdict schema version (see Schema version). |
mode | string | probe, mount or relay | Which checks produced the verdict, matching the subcommand that ran. |
overall | string | one of ok, fix_and_retry, fatal | The roll-up a caller branches on (see Overall verdict). |
checks | array of check objects | The mode's full ordered check list (see The check list). |
Each entry of checks:
| Field | Type | Presence | Meaning |
|---|---|---|---|
id | string | always | Stable identifier from the mode's fixed list. |
status | string | always | One of ok, warn, fail, skipped (see Check status). |
meaning | string | per status | What the outcome means. |
action | string | per status | What the operator does about it. |
url | string | relay mode's TURN and WebSocket relay checks only | The relay url the check is about, as the run lists it. On a TURN check it is the TURN url the check asked, or, on a skipped check, one it did not contact. On a WebSocket relay check it is the WebSocket relay url the check asked. |
transport | string | relay mode's TURN checks only | The transport that url names: udp, tcp or tls. On a udp url, whether it names ?transport=udp or no transport, werift retries a failed UDP allocation over TCP to the same host and port, so an ok there may have been reached over TCP. |
outcome | string | relay mode's WebSocket relay checks only | What the relay answered the proof a run presents: one of admitted, not_admitted, not_reached, certificate_not_verified or unexpected_message (see The relay check). |
close_code | integer | relay mode's WebSocket relay checks, not_admitted only | The close code the relay refused the proof with: one of 1001, 1009, 1011, 4001, 4002, 4003, 4004, 4005, 4007 or 4008 (WEBSOCKET_RELAY.md). |
registrar | string | relay mode's enrollment check only | The registrar invitation_relay names: the one the check enrolled at, or, on a skipped check, the one it did not contact. |
lapses_at | string | relay mode's enrollment check, ok only | When the enrollment lapses, as the registrar stated it: a UTC timestamp, YYYY-MM-DDTHH:MM:SSZ. Omitted when the registrar states no lapse. |
No other member appears on either object. An optional field that has nothing to say is omitted rather than emitted as null, so a consumer tests presence, never a null value.
The line is printable ASCII
Every byte of the emitted line is printable ASCII (U+0020-U+007E). A meaning or an action interpolates text this codebase did not choose: the SMB_PATH subfolder reaches the subdirectory check's meaning verbatim, SMB_SHARE reaches the authentication and share_open checks' meaning, and SMB_SERVER reaches the name_resolution check's action. The doctor validates those three variables against the C0 controls and DEL alone as far as character content goes (the server and share names are also refused a path separator and a leading -), so a C1 control, a bidi override, an astral character, or U+2028/U+2029 in any of them is accepted and reaches the document. The authentication and share_open checks also include an smbclient NT_STATUS token in a meaning; that token is bounded to NT_STATUS_[A-Z_]+ where it is extracted, so it is ASCII by construction and cannot contain such a byte -- the three SMB_* values are the reachable sources. In a relay verdict, each check's url is configuration text, and a url from invitation_relay was chosen by the partner's invitation, as the enrollment check's registrar was. Its lapses_at is the registrar's text, read only where it is a UTC timestamp, so it is ASCII by construction. Bare JSON.stringify escapes only U+0000-U+001F, the quote and the backslash, passing DEL, the whole C1 range, and U+2028/U+2029 through. The verdict therefore rides the same encoder the probe-host-key machine lines do (asciiSafeJsonLine, apps/cli/src/util/jsonLine.ts; see SFTP host-key verification, which specifies the encoder for those lines), which rewrites every UTF-16 code unit outside that range in the encoded text to the \uHHHH escape JSON already defines for it. A launcher that cannot classify a line prints it -- to a terminal, or into a log a person later reads -- and this is what makes doing so safe.
The escapes are JSON's own, so the keys, the value types, and the values a consumer parses back are exactly what a bare JSON.stringify would have produced. This is not a second escaping altitude and not a redaction: a launcher that renders a parsed meaning or action to a human escapes it once, at its own display sink, with nothing here to double (see CONTRIBUTING.md, Operator-facing escaping), and material that must not be shown at all is withheld where the verdict is composed (see What the verdict withholds).
Schema version and compatibility
version is a single integer, currently 1. A consumer reads it first and refuses a value it does not know rather than parsing on.
Within a version:
- An added field is compatible; a consumer ignores members it does not know.
- A check added to a mode appears as an additional entry in
checks, so a consumer keys on the ids it knows rather than on the list's length or an entry's position.
A new version is taken for any of: removing a field or a check id, changing what an existing field or check id means, adding a value to the status or overall vocabulary, or changing which exit code a verdict maps to. Both vocabularies are therefore closed within a version, and a consumer may switch on them exhaustively.
Overall verdict
| Value | Meaning | Rolled up from |
|---|---|---|
ok | Nothing found here blocks an exchange. A warn check is reported under this value: it asks something of the operator without stopping them. | No check has status fail. |
fix_and_retry | The checks ran and at least one returned a verdict the operator can act on before running an exchange. | At least one fail, none of them a failure that made the checks unrunnable. A fail that ends the run early, leaving the checks after it skipped, still rolls up here. |
fatal | The checks could not be run, so nothing was established either way -- neither that the share works nor that it does not. | At least one fail that made the checks unrunnable: the smbclient binary missing from the image, or the mount directory not listable. |
The two failure classes are distinguished by overall alone; which check was the unrunnable one is not itself a field. fatal outranks fix_and_retry because the two ask different things of the caller: a fatal verdict has no action to follow, since the checks that would have produced one never ran.
Check status
| Status | Human label | Meaning | meaning | action |
|---|---|---|---|---|
ok | OK: | The check ran and establishes what it set out to. Nothing is asked of the operator. | optional | never |
warn | WARN: | The check ran and does not block an exchange, but has something the operator should read -- a share that works only with --lockless-rendezvous, one nearly out of space, a folder whose listing was too long to count in full. | always | always |
fail | FAIL: | The check ran and what it tests is not the case. Any fail moves overall to fix_and_retry or fatal. | always | always |
skipped | SKIP: | The check did not establish its property, for one of four reasons the meaning states: an earlier check failed and stopped the run; the check does not apply to the inputs given; the check was attempted and could not be completed; or, in relay mode, a run of the configuration does not contact the host, so the check does not either. Every skipped record has a meaning. | always | optional |
warn is its own status rather than an ok with an action: a consumer classifies on status alone, without inferring severity from which optional fields are present, and both renderings key off the same field -- the human label column above is the whole mapping.
The check list
The id sets of probe and mount are fixed and ordered. Every id below appears in every verdict that mode produces, in the order given, whatever stopped the run: a check that did not run is reported as skipped, never omitted, so a consumer can index the verdict by id without first discovering how far the run got.
probe -- the userspace checks, run over smbclient with nothing mounted:
| Id | What a passing check establishes |
|---|---|
name_resolution | The server name resolves from inside the container. A literal IPv4 address needs no resolution and passes on that basis. |
tcp_445 | A TCP connection to port 445 on the server completes. |
smbclient_available | The smbclient binary is present. Its absence is the failure that makes the rest of these checks unrunnable. |
authentication | The server accepted the credentials. A server that authenticates and then refuses to list its shares is ok with a meaning, not a failure: refusing the share list to ordinary accounts is common and decides nothing. |
share_open | The share opened. When a subdirectory was given, a share root that will not list is ok with a meaning -- rights to one's own folder and nothing above it is the ordinary shape of an agency grant. |
subdirectory | The folder the exchange will run in opened, and how many entries it holds. With no subdirectory configured this is the share root, counted the same way. A folder already holding more entries than the transport's directory-listing bound is a fail, since an exchange there is refused whatever else the checks find, and its action is a folder dedicated to the exchange (see TRANSPORT_BOUNDS.md). The captured output allows each entry line room for a full-length name, but a listing can still be cut short of the bound -- by lines longer than that allowance -- so a listing too long to capture whose counted entries are within the bound is a separate warn: the count is reported as a lower bound, the meaning states that the listing could not be captured in full, and the action asks the operator to confirm the folder is within the bound or use a dedicated one. |
free_space | The free space the server reported for the share. Below 100 MB, or none at all, is a warn; a server that reports no figure, or a listing too long to capture through to the figure after its last entry, makes this skipped. |
write | A file was created in that folder. |
rename | That file was renamed. |
delete | The renamed file was deleted. |
marker | The cross-check file was left behind for doctor mount to find. skipped when no marker or token was supplied, and when the share refused the write. |
mount -- the kernel checks, run against an already-mounted directory:
| Id | What a passing check establishes |
|---|---|
mount_readable | The directory exists and can be listed. Its failure is the failure that makes the rest of these checks unrunnable. |
marker | The file doctor probe left behind is here and is this run's, so both sets of checks examined the same directory. A marker from a different run is a warn, and so is a matching marker the mount cannot delete; an absent one is a fail, since the mount and the probe are then pointing at different directories. skipped when no marker or token was supplied. |
write_rename | A file was written under a temporary name and renamed into place, the shape Alcove writes every message with. A write or rename that fails is a fail, and so is one of its two working names, .alcove-w.tmp and .alcove-w2.tmp, left in the directory where the run cannot remove it first. Either fail stops the run, leaving the checks after it skipped. |
exclusive_create | The share refuses to create a file that already exists -- the O_EXCL refusal Alcove's rendezvous uses to decide which side goes first. Only an "already exists" error (EEXIST) on the second create counts as that refusal. A share that does not refuse it is a warn; a share where the create could not be tested at all -- the first create failed, or the second failed with any other error -- is skipped, with an action naming the same remedy. A working name left in the directory that the run cannot remove first is a fail: the check never ran, and the name has to be cleared by hand. |
rename_onto_existing | The share renames a file onto an existing one, which Alcove does when two sides meet at once. A share that will not is a warn; staging the two files that the rename needs failing is skipped; and, as for exclusive_create, an unremovable working name is a fail. |
The fails of exclusive_create and rename_onto_existing are the occupied-working-name case, and it is the only fail either of those checks emits. Their three names are fixed -- .alcove-x.tmp for exclusive_create, .alcove-a.tmp and .alcove-b.tmp for rename_onto_existing -- and each is removed before its check runs; a name still present after that attempt puts overall at fix_and_retry, never fatal, and unlike the write_rename case the checks after it still run.
relay -- one check per TURN url a run of the configuration lists, in the order the run lists them (selectRunRelay: the connection's own turn entries, then the invitation_relay turn urls no own entry already names, less the urls allowed_relays leaves out), preceded by the enrollment check where invitation_relay names a registrar, and followed by one check per WebSocket relay a run dials, in the order the run lists them (selectRunRelay: the connection's own websocket_relay, then the one invitation_relay names where it is another url, less the urls allowed_relays leaves out). Only turn_1, each WebSocket relay check, and an enrollment the run would make contact a host; every other check is skipped:
| Id | What a passing check establishes |
|---|---|
enrollment | The registrar invitation_relay names enrolled the exchange's relay key, as it does for a run before it dials when the url the run allocates on is the invitation's and is sent the minted credential, or when the run dials the invitation's WebSocket relay under the exchange id it derives (runEnrollsAtInvitationRegistrar). Present, and first, only where invitation_relay names a registrar; skipped, with nothing contacted, where a run of the configuration does not enroll there. |
turn_1 | The relay at the check's url, the first the run lists and the only one a run allocates on, gave this host a relay candidate. werift allocates on the first TURN url a peer is given and ignores the rest (PROTOCOL.md). Present only where a run lists a TURN url. |
turn_2, turn_3, ... | Always skipped: a run lists the relay at the check's url but does not contact it, so the check does not either. The number is the url's 1-based position in the run's list, so the id set is as long as that list, and a consumer reads url for which relay a check is about. |
websocket_1, websocket_2 | The WebSocket relay at the check's url, which a run dials, admitted the proof a run presents there. A run dials every WebSocket relay it lists, so each is contacted. The number is the relay's 1-based position in the run's list, and a run lists two at most. Present only where a run lists a WebSocket relay. |
The relay check
doctor relay runs only when it is named: doctor probe and doctor mount contact no relay and no registrar.
The check contacts no host a run of the configuration does not contact, and of a run's contacts only these: the first TURN url the run lists, every WebSocket relay the run dials, and the registrar invitation_relay names only where runEnrollsAtInvitationRegistrar is true for the configuration. It discloses this machine's address, and the credential or proof a run presents, to no host a run of the configuration would not disclose them to.
Refusals. A configuration whose
connection.channelis notwebrtc, one that configuresice_provision, and one that lists no TURN url and no WebSocket relay are each a usage error (exit 64), refused before anything is contacted. So is one whose every relay urlallowed_relaysleaves out, TURN and WebSocket alike, and its refusal names those urls. So is one that lists a WebSocket relay and sets norole, since the proof a WebSocket relay takes names this side's role.The host statement. Before its first request, the check prints the hosts it will contact:
- the first TURN url, with the transport it names, whether it came from
connection.turnor the invitation, that it is the relay a run allocates on, and that whoever runs it learns this machine's network address; - each WebSocket relay a run dials, whether it came from
connection.websocket_relayor the invitation, that the check opens two connections to it, presents on each the proof a run presents there, and closes both once the relay answers, and that whoever runs it learns this machine's network address; - the registrar, where the run would enroll there, with the statement that the check enrolls this exchange's relay key there as a run does and that whoever runs it learns this machine's network address.
It then prints each host the configuration names that it will not contact, with the reason that a run of this configuration does not contact it: every TURN url after the first, which a run does not allocate on, each relay url
allowed_relaysleaves out, stated asLeft out by connection.allowed_relays., and the registrar whereinvitation_relaynames one the run would not enroll at. Whereinvitation_relaynames no registrar, the statement says the check contacts none and enrolls nothing; where the configuration has noinvitation_relayblock, it says nothing about one. Each host is alone on its line, with the explanation on the lines below it and, for a TURN url, its transport and its source there too, so a host longer than the display budget (DISPLAY_REDACTION.md) loses only its own tail on the statement. A check's summary names its url on one line with the rest of the summary, so a url long enough to fill that budget loses the rest of the summary there, while the verdict'surlmember has the url as the run lists it. It prints them to the destination the human check lines use -- stderr, or the--log-file-- with or without--json, at every--log-level,silentincluded. It contacts no other host: notconnection.relay_registrar, no coordination server, and no STUN server.- the first TURN url, with the transport it names, whether it came from
The enrollment. Where the run would enroll at the registrar
invitation_relaynames, the check enrolls the exchange's relay key there before it asks the relays, through the call a run makes before it dials (enrollAtInvitationRelay; PROTOCOL.md): one attempt without a token, under the run's wait. Its outcome is theenrollmentcheck:okwith the lapse the registrar states, orwarnwith the first sentence of the run's warning, holding the registrar's answer, as the summary. Thewarnhas ameaningsaying a run makes the same enrollment, and anactionto run the check again and, if the enrollment keeps failing, to ask the partner whether the registrar their invitation names is up; it adds that a run goes on without the enrollment, and that the invitation's relay refuses the run's credential until either party enrolls the exchange there. The answer is the registrar's text, so it stays in the summary the JSON withholds. A failed enrollment does not stop the check: the relays are still asked. Whereinvitation_relaynames a registrar the run would not enroll at, theenrollmentcheck isskipped, with ameaningthat a run of this configuration does not enroll there, because the relay it allocates on is not one the invitation names or is sent itsconnection.turnentry's own credential, because it proves to the WebSocket relay the invitation names under the exchange id the invitation names, because the WebSocket relay it dials is not one the invitation names, or becauseconnection.allowed_relaysleaves out the relay it names.The request. For the first TURN url alone the check builds a werift peer connection as a run does, except that it gathers relay candidates only, with that url as its only ICE server, presenting what the run presents there: the entry's own
usernameandcredential, or the credential the run mints from the shared secret (relayCredentialForRun). It creates a data channel and a local offer, and waits for gathering to complete. No later TURN url is contacted.The WebSocket relay request. For each WebSocket relay a run dials, the check takes the run's own dial (
webSocketRelayDialsFor), so the exchange id, the role, the relay key and the proof are derived as a run derives them, and parks two sockets on it in this side's role through the run's own relay client (parkWebSocketRelay; WEBSOCKET_RELAY.md). Each socket sends the hello a run sends, and corrects an expired proof on the relay's clock as a run does. The relay sends nothing to an admitted hello until a partner arrives, and it checks the role only once the proof has verified (WEBSOCKET_RELAY.md), so the second socket it admits is refused as role taken (4006). The check reads that refusal, orpairedon either socket, as the admission. It closes both sockets once it has an answer, with 1000, as a run closes a socket on which it has not sent its start, except a socket that received a frame the client does not take, which it closes with 4000, as a run abandons such a socket. Neither socket sends a start, so the check opens no relayed path and sends no exchange message.The stated limit of the WebSocket relay request. The check stops at admission. It establishes that this host reached the relay and that the relay accepted the proof a run presents, and not that a relayed path between the two parties would carry the exchange: that needs the partner, which the check does not wait for. A partner already waiting at the relay in the other role is paired with one of the check's sockets, and the check's close then reads to that partner as a partner leaving the pairing, on which its relay client dials again (WEBSOCKET_RELAY.md).
The bound. The requests run at once, under one timer of 30 seconds, an arbitrary working value, started once the enrollment has ended. A gathering that has not completed when it expires is reported as not reached, and the peer connection is closed, ending the gather. A WebSocket relay that has not answered when it expires is reported as not reached, and its sockets are closed. The certificate check of a WebSocket relay runs under the same timer as well as its own budget, the signaling certificate check's (WEBRTC_TRANSPORT.md), so the check takes no longer than the one bound. A gather that throws rather than ending ends the check with an error and no verdict, and so does a WebSocket relay request that the run's relay client ends with a failure of its own, such as a proof it could not build or a socket it could not dial, rather than with an answer from the relay. A close from the relay, and a message from it that the run's relay client does not take, are that relay's own outcome, and every other check is reported beside it.
The outcomes. The first url is reached when a relay candidate is among this side's candidates (the same reading of the ICE statistics a run's failure diagnosis makes). Its check,
turn_1, isokwhen it is reached, andfailotherwise, withno relay candidate gatheredin the summary and the remedy a run's relay failure has in CLI.md as theaction. Where a run also dials a WebSocket relay, thatactionadds that each WebSocket relay line below shows whether the exchange can still go that way. werift reports no per-server error from the peer connection, so a relay that refused the allocation, one that never answered, and one whose certificate did not verify are one outcome, and themeaningsays the reason is not reported. A relay that has not enrolled the exchange's relay key refuses the minted credential, so where the enrollment failed and no run on either side has enrolled the key, the invitation's url is not reached on any network, and its check'smeaningnames the failed enrollment as the likely cause. Every later url's check isskipped, with ameaningthat a run of this configuration does not contact it, since a run allocates on the first url alone, and anactionto list it first and run the check again if it should be the run's relay. The human rendering prints that sharedmeaningandactiononce, under the last of them.The WebSocket relay outcomes. Each WebSocket relay's check is
okwithoutcomeadmittedwhere the relay admitted the proof. It isfailotherwise, with one of four outcomes, each with its ownmeaningandaction:not_admitted, where the relay refused the hello with a close code WEBSOCKET_RELAY.md lists for a hello it refuses, held inclose_code. A first 4005 on a socket is not an answer, since the relay client makes the proof again at once on the relay's clock. Theactionfollows the code:- For 4003, run the check again and ask the partner whether the registrar their invitation names is up, where a run enrolls at that registrar on this relay's account (
webSocketRelayEnrollsAtInvitationRegistrar, decided by the url asrunEnrollsAtInvitationRegistraris); ask the partner to enroll the exchange's relay key at their relay's registrar with their relay-owner token, which this side does not hold, where the relay is the invitation's WebSocket relay dialed under the exchange id the invitation names (webSocketRelayIsPartnersUnderInvitationExchangeId, decided by the url likewise); and otherwise enroll the exchange's relay key withalcove enroll-relay. Where the enrollment failed and a run enrolls there on this relay's account, the 4003meaningnames the failed enrollment as the likely cause. - For 4004, register the current key with
alcove enroll-relay --replace-relay-key. - For a 4005 after the relay client made the proof again on the relay's clock, the
meaningsays this computer's clock is not the cause, and theactionis to run the check again and, if the relay refuses the proof the same way, check the relay and the exchange's enrollment at its registrar, as the relay client's own failure says. A 4005 whose reason is not a second the relay client reads is the answer at once, since no proof can be made on the relay's clock; for it theactionis to correct this computer's clock first. - For 4007, check the network path to the relay, including any proxy.
- For 4003, run the check again and ask the partner whether the registrar their invitation names is up, where a run enrolls at that registrar on this relay's account (
unexpected_message, where the relay answered the proof with a message the run's relay client does not take at that point, so the url may not name an Alcove WebSocket relay. Theactionis to check that the url names one.certificate_not_verified, where a socket closed before it opened and the certificate check a run makes of a failed signaling socket (WEBRTC_TRANSPORT.md) found that the relay's TLS certificate did not verify on this host. Theactionnames the remedy where the network intercepts TLS: add its certificate authority to this machine's trust store, or name a file holding it inNODE_EXTRA_CA_CERTS.not_reached, for anything else: a socket that closed before the relay answered the proof, a certificate that verified or could not be checked, or no answer within the bound. Where Node's environment proxying is on, the certificate is not checked, and themeaningandactionsay so.
Rollup. As Overall verdict states, a
failmakesoverallfix_and_retry, and awarnorskippedleaves itok. Onlyturn_1and the WebSocket relay checks can fail, so they alone decideoverall. When any relay the check asks is not reached, not admitted, or does not answer as an Alcove WebSocket relay,overallisfix_and_retry, even though a run may still succeed over another relay the configuration lists. A failed enrollment is awarnand leavesoverallok: the enrollment serves the relays a run uses, and their own checks have the consequence. The check reports nofatal.
What the verdict withholds
The record set behind both renderings has three fields the JSON document does not: the check's summary headline, a detail excerpt of the tool's own output behind a failure, and the marker for a failure that stopped the run.
In relay mode the summary is composed from the check's own id, status, url, transport, registrar, lapses_at, outcome and close_code and the check's 30-second bound, by core's relayCheckHeadline (packages/core/src/relayCheck.ts). The one exception is a warn enrollment, whose summary also holds the registrar's answer. A consumer that composes a headline from the verdict with that function states the line the CLI prints, less that answer. The host statement is core's too (relayCheckPlan and relayHostStatement), so a consumer can state the hosts a check of a configuration contacts before it runs one. Neither rendering, nor the host statement, holds the credential or the proof a run presents, or anything else derived from the shared secret, such as the relay key or an exchange id the run derives.
detail is server-controlled text -- smbclient can answer with a whole share listing -- so a consumer receives the classified meaning and action rather than raw bytes to re-render. In the human rendering, where it is shown, a private-key strip runs over the whole excerpt first, before it is bounded. A key block arrives in its canonical multi-line form, so a strip applied to the bounded lines instead would match its BEGIN line alone and render the body verbatim, and this rendering's sink does not pass through the logger that would otherwise catch it (see DISPLAY_REDACTION.md). The stripped excerpt is then bounded to the first 2,000 characters and the first 24 non-blank lines, with a trailing ... (output truncated) line when either bound cut it -- measured against the stripped text, so a replacement that shortened the excerpt without either bound dropping anything does not report a cut that did not happen. The operator is asked to send that rendering on to whoever is helping them, so it is capped rather than sprayed. The subdirectory check reports a folder's entry count for the same reason and never its filenames: those are the operator's own names on their own share. For the same reason the excerpt behind a failure drops every directory-listing entry line from the tool's output, so a listing that failed partway -- one the wait cut short included -- leaves only smbclient's own messages in it.
Exit codes
The exit code is set from overall, identically with and without --json, so a caller that does not parse the verdict still receives the classification:
overall | Code | Name |
|---|---|---|
ok | 0 | success |
fix_and_retry | 78 | EX_CONFIG |
fatal | 69 | EX_UNAVAILABLE |
The two nonzero values follow BSD sysexits, as the rest of the CLI does: fix_and_retry is a configuration the operator changes, and fatal is something the doctor depends on not being available (the smbclient binary, or the mounted directory itself).
Every code above is below 125, which Docker reserves for its own failure to start a container, so a caller running the doctor in a container can tell "Docker could not run it" from any verdict the command reaches.
A usage error -- a missing or malformed input, a bad flag, or a configuration doctor relay refuses -- is not in that set: it exits 64 (EX_USAGE) and prints no verdict on either stream, because the checks never ran. A caller that sees 64 has established nothing about the share.
A local fault that stops doctor probe before it can compose a verdict -- the temporary directory or the credentials file in it cannot be created or restricted to its owner, or a working file cannot be written locally -- also prints no verdict on either stream, and exits 69: something the checks depend on was not available, the same class a fatal verdict reports. A caller that sees 69 with nothing on stdout treats it as fatal.
64 is also what an image predating this command answers to doctor probe: option validation does not reject the two words as positionals, so they reach the zero-setup default command, which reads the first of them as a server URL and refuses it. No exit code separates that from a doctor rejecting an input, and a published image cannot be changed after the fact. So a caller that may meet an older image establishes the command's presence before it runs any check -- doctor --help answers that without reading an SMB_* value or touching the share -- rather than reading a 64 as a verdict about its own inputs.
Cleanup limits
Probe cleanup is attempted, never guaranteed. Every working file a run creates is named alcove-probe-<suffix>.tmp or alcove-probe-<suffix>.tmp.renamed, the suffix being the run's SMB_TOKEN or a random value, and on every handled exit path one masked delete, del alcove-probe-<suffix>.tmp*, is issued for whichever of them may still be on the share. Its outcome is not re-verified: a share that refuses deletes or a transport that dies mid-run leaves the file in place. The next probe run deletes alcove-probe-*.tmp* before its own staged test, which is the designed safety check for such residue.
An interrupt (SIGINT or SIGTERM) is a handled exit path, bounded at 8 seconds from the signal to its re-raise. The run prints one line to stderr, cleaning up, up to 8 s; press Ctrl-C again to skip, stops the smbclient command in flight (SIGTERM, then SIGKILL 2 seconds later), issues the masked delete under a 3-second timeout, removes its local credentials directory, and re-raises the signal. Whatever is still running when the bound expires is abandoned; the credentials directory is removed before the re-raise in every case. A second interrupt abandons the cleanup at once, likewise removing the credentials directory before it re-raises.
A SIGKILL cannot be handled, so one sent during the cleanup leaves the credentials file on disk in an alcove-doctor-* directory under the temporary directory, and may leave probe files on the share. A supervisor's stop timeout must be at least the 8-second bound.
The marker file is the single persistent artifact: the probe leaves it behind, and doctor mount consumes it on a matching cross-check.