mtp

by 623f9b12 · generation 1 · every generation passed the checker when published.

MTP/PTP initiator session and transaction state machine over USB bulk pipes: Command -> optional Data -> Response with per-session transaction IDs. Checks tid matching, at-most-one outstanding transaction, data-phase direction, session-lifecycle recovery from a stale open session, and SendObjectInfo/SendObject pairing (file vs folder flavors).

Raw .tla Raw .cfg

mtp.tla

MODULE mtp

MTP (Media Transfer Protocol, over the PTP/USB transport) transaction and session state machine as implemented by the initiator in mtp.ts.

An initiator (browser, via WebUSB) and a responder (device) exchange containers over a pair of bulk pipes. Each transaction is Command -> optional Data -> Response, where the data phase runs in one direction chosen by the operation: responder->initiator for reads (GetDeviceInfo, GetStorageIDs), initiator->responder for writes (SendObjectInfo, SendObject, via transaction()'s dataOut argument), and absent for DeleteObject and the session operations.

Transaction IDs increase per session (OpenSession resets the counter to 0, matching MtpDevice.openSession()), and transaction() rejects a RESPONSE container whose tid differs from the command's tid (RecvRespBadTid, a thrown Error); NoTransportFailure shows this path is unreachable against a conforming responder.

Session lifecycle: no session -> OpenSession(1) -> session-required ops -> CloseSession. GetDeviceInfo is allowed outside a session. The device may start with a stale open session (devSession = TRUE initially), answered with SessionAlreadyOpen; the initiator recovers by closing and reopening, exactly as openSession() does in mtp.ts.

SendObjectInfo comes in two flavors distinguished by the ObjectInfo dataset's format code: - file-flavored (sendObject() in mtp.ts): announces incoming bytes; SendObject must follow immediately, and the responder holds the ObjectInfo as pending until it does. - folder-flavored (createFolder() in mtp.ts, format = Association): a complete, terminal operation on its own. No SendObject follows, and it clears any notion of pending file info on the responder. SendObject without an immediately preceding successful file-flavored SendObjectInfo is answered NoValidObjectInfo; the initiator never issues that sequence.

EXTENDS Naturals, Sequences
CONSTANT MaxTid Bound on the tid counter; sent tids stay in 0..MaxTid-1.
VARIABLES
pc, initiator program counter (which command comes next)
phase, "ready" (may send) or "await" (transaction outstanding)
nextTid, initiator's tid counter (this.tid in mtp.ts)
gotData, initiator consumed a Data container this transaction
objInfoDone, initiator: file SendObjectInfo succeeded, SendObject may follow
outCmd, the outstanding command from the initiator's view
cmdPipe, bulk-out pipe: commands and I->R data heading to the device
respPipe, bulk-in pipe: containers heading to the initiator
devSession, responder-side session state
devObjInfo responder holds a valid FILE ObjectInfo awaiting SendObject
vars ≜ ⟨pc, phase, nextTid, gotData, objInfoDone, outCmd,
cmdPipe, respPipe, devSession, devObjInfo
NoCmd ≜ [op"none", tid ↦ 0]
DataInOps ≜ {"GetDeviceInfo", "GetStorageIDs"} responder -> initiator

Initiator -> responder data phase. Both SendObjectInfo flavors carry an ObjectInfo dataset; SendObject carries the object bytes.

DataOutOps ≜ {"SendObjectInfoFile", "SendObjectInfoFolder", "SendObject"}
SessionOps ≜ {"GetStorageIDs", "SendObjectInfoFile", "SendObjectInfoFolder",
"SendObject", "DeleteObject"}
AllOps ≜ {"GetDeviceInfo", "OpenSession", "CloseSession"} ∪ SessionOps
RCs ≜ {"OK", "SessionAlreadyOpen", "SessionNotOpen",
"NoValidObjectInfo", "none"}
PCs ≜ {"maybeInfo", "open", "recoverClose", "recoverOpen",
"getStorage", "mkFolder", "sendObjInfo", "sendObj", "delete",
"close", "done", "failed"}
Cmd(o, t) ≜ [kind"CMD", opo, rc"none", tidt]
DataOutC(o,t) ≜ [kind"DATA", opo, rc"none", tidt]
DataC(t) ≜ [kind"DATA", op"none", rc"none", tidt]
RespC(r, t) ≜ [kind"RESP", op"none", rcr, tidt]
Containers
[kind : {"CMD", "DATA", "RESP"}, op : AllOps ∪ {"none"},
rc : RCs, tid : 0..MaxTid]
Init
pc = "maybeInfo"
phase = "ready"
nextTid = 0
gotData = FALSE
objInfoDone = FALSE
outCmd = NoCmd
cmdPipe = ⟨⟩
respPipe = ⟨⟩
devSessionBOOLEAN TRUE models a stale session left by a crash
devObjInfo = FALSE

Which operation the initiator issues at each program point: the app flow GetDeviceInfo? -> OpenSession -> GetStorageIDs -> createFolder() (a standalone folder-flavored SendObjectInfo) -> sendObject() (file-flavored SendObjectInfo then SendObject) -> DeleteObject -> CloseSession.

OpFor(p) ≜
CASE p ∈ {"open", "recoverOpen"} → "OpenSession"
p ∈ {"recoverClose", "close"} → "CloseSession"
p = "maybeInfo""GetDeviceInfo"
p = "getStorage""GetStorageIDs"
p = "mkFolder""SendObjectInfoFolder"
p = "sendObjInfo""SendObjectInfoFile"
p = "sendObj""SendObject"
p = "delete""DeleteObject"

Initiator actions

transaction(): send a command container with tid = this.tid++, followed immediately by an I->R DATA container when the operation carries dataOut (either SendObjectInfo flavor's ObjectInfo dataset, SendObject's bytes). openSession() first resets this.tid to 0, so OpenSession goes out with tid 0.

Send
phase = "ready"
pc ∉ {"done", "failed"}
LET oOpFor(pc)
tIF o = "OpenSession" THEN 0 ELSE nextTid
INcmdPipe = IF oDataOutOps
THEN cmdPipe ∘ ⟨Cmd(o, t), DataOutC(o, t)⟩
ELSE Append(cmdPipe, Cmd(o, t))
outCmd = [opo, tidt]
nextTid = t + 1
phase = "await"
gotData = FALSE
UNCHANGEDpc, objInfoDone, respPipe, devSession, devObjInfo

GetDeviceInfo before OpenSession is optional in the app flow.

SkipInfo
pc = "maybeInfo"
phase = "ready"
pc = "open"
UNCHANGEDphase, nextTid, gotData, objInfoDone, outCmd,
cmdPipe, respPipe, devSession, devObjInfo

Where the initiator goes after a response with code rc, mirroring openSession()'s SessionAlreadyOpen recovery and expectOk()'s throw on any other non-OK code ("failed" = a thrown Error surfaced to app).

NextPC(p, rc) ≜
CASE p = "maybeInfo"IF rc = "OK" THEN "open" ELSE "failed"
p = "open"IF rc = "OK" THEN "getStorage"
ELSE IF rc = "SessionAlreadyOpen"
THEN "recoverClose" ELSE "failed"
p = "recoverClose"IF rc = "OK" THEN "recoverOpen" ELSE "failed"
p = "recoverOpen"IF rc = "OK" THEN "getStorage" ELSE "failed"
p = "getStorage"IF rc = "OK" THEN "mkFolder" ELSE "failed"
p = "mkFolder"IF rc = "OK" THEN "sendObjInfo" ELSE "failed"
p = "sendObjInfo"IF rc = "OK" THEN "sendObj" ELSE "failed"
p = "sendObj"IF rc = "OK" THEN "delete" ELSE "failed"
p = "delete"IF rc = "OK" THEN "close" ELSE "failed"
p = "close"IF rc = "OK" THEN "done" ELSE "failed"

First R->I Data container of a transaction: recorded as the data phase.

RecvData
phase = "await"
respPipe ≠ ⟨⟩
Head(respPipe).kind = "DATA"
gotData = FALSE
gotData = TRUE
respPipe = Tail(respPipe)
UNCHANGEDpc, phase, nextTid, objInfoDone, outCmd,
cmdPipe, devSession, devObjInfo

A second Data container: transaction() would throw "expected response container" -- never treat Data as a Response.

RecvUnexpectedData
phase = "await"
respPipe ≠ ⟨⟩
Head(respPipe).kind = "DATA"
gotData = TRUE
pc = "failed"
phase = "ready"
outCmd = NoCmd
respPipe = Tail(respPipe)
UNCHANGEDnextTid, gotData, objInfoDone, cmdPipe, devSession, devObjInfo

Response container with the matching tid completes the transaction. A successful FILE SendObjectInfo arms the SendObject that must follow. A folder SendObjectInfo is terminal: it never arms SendObject, and it clears any stale pairing state. Anything else that finishes the pair (or the session boundary) clears the pairing on the initiator side.

RecvResp
phase = "await"
respPipe ≠ ⟨⟩
Head(respPipe).kind = "RESP"
Head(respPipe).tid = outCmd.tid
pc = NextPC(pc, Head(respPipe).rc)
objInfoDone = IF outCmd.op = "SendObjectInfoFile"
Head(respPipe).rc = "OK"
THEN TRUE
ELSE IF outCmd.op ∈ {"SendObjectInfoFile",
"SendObjectInfoFolder",
"SendObject", "OpenSession",
"CloseSession"}
THEN FALSE
ELSE objInfoDone
phase = "ready"
gotData = FALSE
outCmd = NoCmd
respPipe = Tail(respPipe)
UNCHANGEDnextTid, cmdPipe, devSession, devObjInfo

transaction() compares the RESPONSE container's tid against the command's tid and throws on mismatch (stale container on the pipe) instead of accepting the response. Against a conforming responder this action is never enabled -- NoTransportFailure proves it.

RecvRespBadTid
phase = "await"
respPipe ≠ ⟨⟩
Head(respPipe).kind = "RESP"
Head(respPipe).tidoutCmd.tid
pc = "failed"
phase = "ready"
gotData = FALSE
outCmd = NoCmd
respPipe = Tail(respPipe)
UNCHANGEDnextTid, objInfoDone, cmdPipe, devSession, devObjInfo

Responder (device) action: consume a command (plus its I->R data phase when the operation has one), produce optional R->I Data then a Response, echoing the command's transaction ID. A successful FILE SendObjectInfo leaves the responder holding a valid ObjectInfo; a folder SendObjectInfo completes immediately and clears it; any other operation clears it, and SendObject without it is answered with NoValidObjectInfo.

DevHandle
cmdPipe ≠ ⟨⟩
Head(cmdPipe).kind = "CMD"
LET cHead(cmdPipe) IN
IF c.opDataOutOps
THEN Drain the I->R data phase along with the command.
Len(cmdPipe) ≥ 2
cmdPipe[2].kind = "DATA"
cmdPipe[2].tid = c.tid
cmdPipe = SubSeq(cmdPipe, 3, Len(cmdPipe))
ELSE cmdPipe = Tail(cmdPipe)
CASE c.op = "GetDeviceInfo"

Allowed outside a session; R->I data phase.

respPipe = respPipe ∘ ⟨DataC(c.tid), RespC("OK", c.tid)⟩
devObjInfo = FALSE
UNCHANGED devSession
c.op = "OpenSession"
devObjInfo = FALSE
IF devSession
THENrespPipe = Append(respPipe,
RespC("SessionAlreadyOpen", c.tid))
UNCHANGED devSession
ELSEdevSession = TRUE
respPipe = Append(respPipe, RespC("OK", c.tid))
c.op = "CloseSession"
devObjInfo = FALSE
IF devSession
THENdevSession = FALSE
respPipe = Append(respPipe, RespC("OK", c.tid))
ELSErespPipe = Append(respPipe,
RespC("SessionNotOpen", c.tid))
UNCHANGED devSession
c.op = "GetStorageIDs"
devObjInfo = FALSE
respPipe = IF devSession
THEN respPipe ∘ ⟨DataC(c.tid),
RespC("OK", c.tid)⟩
ELSE Append(respPipe,
RespC("SessionNotOpen", c.tid))
UNCHANGED devSession
c.op = "SendObjectInfoFile"

I->R data phase already drained above; on success the responder now expects SendObject next.

IF devSession
THENdevObjInfo = TRUE
respPipe = Append(respPipe, RespC("OK", c.tid))
ELSEdevObjInfo = FALSE
respPipe = Append(respPipe,
RespC("SessionNotOpen", c.tid))
UNCHANGED devSession
c.op = "SendObjectInfoFolder"

Association format: the object (folder) is complete with this transaction alone. No pending file info is set -- and any previously pending file info is invalidated.

devObjInfo = FALSE
respPipe = IF devSession
THEN Append(respPipe, RespC("OK", c.tid))
ELSE Append(respPipe,
RespC("SessionNotOpen", c.tid))
UNCHANGED devSession
c.op = "SendObject"
devObjInfo = FALSE consumed (or rejected) either way
respPipe = IF ¬devSession
THEN Append(respPipe,
RespC("SessionNotOpen", c.tid))
ELSE IF devObjInfo
THEN Append(respPipe, RespC("OK", c.tid))
ELSE Append(respPipe,
RespC("NoValidObjectInfo", c.tid))
UNCHANGED devSession
c.op = "DeleteObject"
devObjInfo = FALSE
respPipe = IF devSession
THEN Append(respPipe, RespC("OK", c.tid))
ELSE Append(respPipe,
RespC("SessionNotOpen", c.tid))
UNCHANGED devSession
UNCHANGEDpc, phase, nextTid, gotData, objInfoDone, outCmd
Terminating
pc ∈ {"done", "failed"}
UNCHANGED vars
Next
Send
SkipInfo
RecvData
RecvUnexpectedData
RecvResp
RecvRespBadTid
DevHandle
Terminating
SpecInit ∧ □[Next]vars

Invariants

TypeOK
pcPCs
phase ∈ {"ready", "await"}
nextTid ∈ 0..MaxTid
gotDataBOOLEAN
objInfoDoneBOOLEAN
outCmd ∈ [op : AllOps ∪ {"none"}, tid : 0..MaxTid]
cmdPipeSeq(Containers)
respPipeSeq(Containers)
devSessionBOOLEAN
devObjInfoBOOLEAN

The initiator never has to treat an unexpected container as a response, no operation it issues is rejected, and the tid check in transaction() never fires: pc "failed" (a thrown Error in mtp.ts) is unreachable against a spec-conforming device, including the stale-session start. In particular NoValidObjectInfo is unreachable: the standalone folder SendObjectInfo never leads the initiator into an unpaired SendObject.

NoTransportFailurepc"failed"

Every container heading to the initiator carries the transaction ID of the one outstanding command, so transaction()'s tid check accepts exactly the response it is waiting for.

RespTidMatchesOutstanding
i ∈ 1..Len(respPipe) :
outCmdNoCmd
respPipe[i].tid = outCmd.tid

At most one transaction is outstanding on the pipes at any time (command plus at most one I->R data container outbound, data plus response inbound).

AtMostOneOutstanding
Len(cmdPipe) ≤ 2
Len(respPipe) ≤ 2
∧ (phase = "ready") ⇒ (cmdPipe = ⟨⟩ ∧ respPipe = ⟨⟩)
∧ (phase = "ready") ⇔ (outCmd = NoCmd)

Session-required operations only ever sit on the pipe while the device-side session is open.

SessionOpsOnlyWhenOpen
∧ ∀ i ∈ 1..Len(cmdPipe) :
cmdPipe[i].opSessionOpsdevSession
outCmd.opSessionOpsdevSession

Transaction IDs increase monotonically: the outstanding command always carries the newest tid, and the counter only moves forward between OpenSession resets.

TidMonotonic
outCmdNoCmdoutCmd.tid = nextTid - 1

A transaction sees at most one data phase before its response.

SingleDataPhase
i ∈ 1..Len(respPipe) :
respPipe[i].kind = "DATA"
∧ ¬gotData
∧ ∀ j ∈ 1..Len(respPipe) : jirespPipe[j].kind"DATA"

The data phase runs in the direction the operation dictates: R->I data only for read operations, I->R data only for write operations, and an I->R data container always trails its own command on the bulk-out pipe.

DataDirectionOK
∧ ∀ i ∈ 1..Len(respPipe) :
respPipe[i].kind = "DATA"outCmd.opDataInOps
∧ ∀ i ∈ 1..Len(cmdPipe) :
cmdPipe[i].kind = "DATA"
cmdPipe[i].opDataOutOps
i > 1
cmdPipe[i-1].kind = "CMD"
cmdPipe[i-1].op = cmdPipe[i].op
cmdPipe[i-1].tid = cmdPipe[i].tid

Pairing: the initiator never issues SendObject without a successful FILE-flavored SendObjectInfo as the immediately preceding operation, and whenever SendObject is in flight the responder is still holding that valid file ObjectInfo (no operation slipped in between), so NoValidObjectInfo is unreachable.

SendObjectPaired
outCmd.op = "SendObject"objInfoDone
∧ ∀ i ∈ 1..Len(cmdPipe) :
cmdPipe[i].op = "SendObject" ⇒ (objInfoDonedevObjInfo)

A folder-flavored SendObjectInfo is terminal: right after createFolder() completes (pc has advanced past mkFolder with the transaction drained), neither side holds pending file object info -- the folder flavor never arms a SendObject.

FolderInfoNeverArms
∧ (pc = "sendObjInfo"phase = "ready") ⇒
objInfoDone ∧ ¬devObjInfo)
outCmd.op = "SendObjectInfoFolder" ⇒ ¬objInfoDone

mtp.cfg

SPECIFICATION Spec
CONSTANT MaxTid = 7
INVARIANTS
TypeOK
NoTransportFailure
RespTidMatchesOutstanding
AtMostOneOutstanding
SessionOpsOnlyWhenOpen
TidMonotonic
SingleDataPhase
DataDirectionOK
SendObjectPaired
FolderInfoNeverArms
CHECK_DEADLOCK FALSE

Generations

genchangesdistinct statesdepthpublishedraw
1 (latest) Module renamed from webmtp to mtp (library renamed to mtp.js); model unchanged. 42 24 2026-07-27 14:23:14 UTC .tla .cfg

Defend this spec

Ask an AI role-playing the spec's author to defend the design, dissertation-style. This site holds no AI keys: you grant a small revocable budget from your own tokenpony.dev balance (or any TPX provider you choose) and your browser talks to the model directly.

Loading…