How Drivel actually runs, in the order things happen. Architecture
is the static view — what talks to what; this is the dynamic one. Section numbers
point into DESIGN.md for the reasoning.
Nothing on this path blocks the FUSE reply. The mount's only obligation is to put an event on a buffered channel.
sequenceDiagram
autonumber
actor app as Application
participant vfs as internal/vfs<br/>(FUSE node + handle)
participant back as Backing dir
participant eng as Engine.Run<br/>(one goroutine)
participant w as Worker<br/>(path-hashed)
participant st as provider.Store
participant state as state (bbolt)
app->>vfs: write(fd, buf, off)
vfs->>back: pwrite
vfs-->>app: return (never waits on network)
Note over vfs: the handle records<br/>the dirty extent
app->>vfs: close(fd)
vfs->>back: fstat — grow the extent set<br/>to the file's real size
vfs->>eng: fsevent.Event{OpWrite, path, Dirty}
eng->>eng: coalesce per path,<br/>re-arm the -push-delay timer<br/>(capped by the max-wait deadline)
Note over eng: a structural op (rename,<br/>unlink) flushes that path first,<br/>preserving per-path order
eng->>w: dispatch after the timer fires
w->>w: three gates (below)
w->>st: Put / PutRange
st-->>w: RemoteFile{hash, version}
w->>state: record echo (§4)
Three details that look incidental and are not:
Releasefstats the backing file before the wrapped handle closes. A handle only sees its own writes, so its idea of the file's length stops at the last byte it wrote. Without the fstat the engine's size cross-check rejects every extent set and range writes silently never fire.- Workers are chosen by path hash, so all operations on one path are ordered with respect to each other, while unrelated paths proceed in parallel.
- The
OpWritearrives onclose, not on eachwrite(2)— note where the arrow leaves the diagram. That is why-push-delaydefaults to a value as short as 300ms: it is merging an edit's few closes, not batching a transfer, and a 50 GB copy passes through it as one event — a new file'sOpCreatealso leaves at the creating handle's firstFlush(synchronous withclose(2), whereReleaseis not), not fromCreate, which until 2026-10-18 made every copy outlasting the window two uploads. It also means a file held open and never closed — new or not — produces no event at all, at any setting, and is reached only by the sweep. Because each later event re-arms the timer, the window alone cannot bound how long a path waits; the max-wait deadline, counted from the change that made the path pending, is what stops a never-quiet path from being held until shutdown.
Every content upload runs this. Anything that does not clear a gate falls through
to the whole-file Put that M1–M5 always did — that fallback is never wrong,
only slower, which is what makes the whole optimisation safe.
flowchart TD
start["pushContent(path, dirty)"] --> ph{"Is it a<br/>placeholder? (M5)"}
ph -->|yes, or unsure| skip1["SKIP — nothing local to push"]
ph -->|no| small{"size < one block?"}
small -->|yes| put["Whole-file Put<br/>(chunked, resumable)"]
small -->|no| stat["one Stat: what does<br/>the remote hold now?"]
stat --> rw{"RangePutter?<br/>extents known?<br/>remote size == local?<br/>remote still matches the echo?"}
rw -->|all yes| patch["PutRange — send only<br/>the changed extents"]
rw -->|any no| hash{"local bytes hash to<br/>what Stat just reported?"}
patch -->|error| hash
hash -->|yes| skip2["SKIP — remote already<br/>holds these bytes"]
hash -->|no| put
put --> echo["record echo"]
patch --> echo
skip2 --> done([done])
skip1 --> done
echo --> done
classDef danger fill:#7a1f1f,stroke:#3d0f0f,color:#fff
classDef safe fill:#1f6f43,stroke:#0d3b23,color:#fff
class ph danger
class put safe
Why it is in this order:
- The placeholder gate is first and nothing may get in front of it. A placeholder holds zero bytes at full apparent size; uploading one replaces the remote file with nothing. It fails safe — unsure means "placeholder" means skip. Do not replace it with a size heuristic: a legitimate truncate-to-zero looks identical and must still sync.
- The range gate runs before the hash gate because hashing reads the whole file. Checking "did anything change?" first would spend a 4 GB read to avoid a 4 MiB upload.
- The range gate requires the remote to still match the echo record. A
whole-file
Putover someone else's edit loses it per documented §6 policy — bad, but recoverable, and the loser's bytes existed as a coherent version. Splicing extents into a diverged remote makes a hybrid file that existed nowhere, with no conflict copy. Diverged, no echo, or no state store ⇒ decline. - The hash gate compares against what
Statsays the remote holds now, not against the echo. A stale echo would otherwise skip the push forever and never restore the file. - Both gates share one
Statand are skipped below one block, where the round-trip costs about what the upload would.
Drive implements no RangePutter — files.update replaces content wholesale —
and that is deliberate, not a gap. What Drive gets from this is the hash gate plus
chunked resumable sessions.
sequenceDiagram
autonumber
participant dl as Downloader.Run
participant st as provider.ChangeSource
participant state as state (bbolt)
participant back as Backing dir
participant hyd as Hydrator<br/>(lazy mode only)
loop adaptive interval
dl->>state: read cursor
dl->>st: Changes(cursor)
st-->>dl: []RemoteChange, next cursor
loop each change
alt removed
dl->>back: RemoveAll (idempotent)
dl->>state: forget baseline
else Google-native doc
Note over dl: no byte stream —<br/>logged, never materialised
else
dl->>state: GetEcho(path)
alt echo matches hash/version
Note over dl: our own upload<br/>coming back — drop it (§4)
else lazy + local placeholder
dl->>hyd: re-stamp with new metadata
else local bytes already identical
dl->>state: record echo only
else local diverged from baseline
dl->>back: conflict copy (§6),<br/>last-writer-wins by mtime
else
dl->>back: download, or write a placeholder
dl->>state: record echo
end
end
end
dl->>state: store next cursor
end
Echo suppression (§4) is the load-bearing correctness concern in this codebase. Without it every upload comes back through the feed as a remote change and is re-applied, which at best wastes a round trip and at worst overwrites a newer local edit. Both directions consult the same store.
Note the two distinct "nothing to do" outcomes: an echo match (this is our own content) and identical local bytes (common on a first sync over a directory that already mirrors Drive). They are reached by different evidence and both matter.
The change feed only reports what changed after a cursor was taken, so a Drive
that existed before the first mount is invisible to it. A sweep is what makes the
tree present. It runs off the FUSE path, on a first run, a resumed sweep, a dead
cursor, an elapsed -sweep-interval, -resync, or — since M27 — a push the
outbound path gave up on, which is the one trigger that fires with
-sweep-interval 0 as well.
sequenceDiagram
autonumber
participant dl as Downloader
participant st as provider.Enumerator
participant state as state (bbolt)
participant eng as Engine (Pusher)
Note over dl,state: 1. SNAPSHOT
dl->>st: StartCursor()
st-->>dl: token
dl->>state: persist sweep{gen, token, started}
Note over dl,st: 2. ENUMERATE
loop each page
st-->>dl: []RemoteFile
dl->>state: MarkSeen(gen, paths) — before acting
loop each object
dl->>dl: reconcileRemote — same rows as an inbound apply
end
dl->>state: persist sweep cursor (resume point)
end
Note over dl,eng: 3. LOCAL WALK
dl->>dl: walk the backing tree
Note over dl: skip placeholders, non-regular files, .drivel-*,<br/>sibling conflict copies, unsynced conflict folders
dl->>eng: Push(OpWrite) for anything with<br/>no baseline and no seen mark
dl->>eng: Push(OpWrite) for a baseline whose LOCAL copy<br/>diverged before this mount opened (M27 part 4)
Note over dl,state: 4. DELETES — only now, only if complete
dl->>state: EachUnseenEcho(gen)
dl->>dl: apply the four guards, cap at -max-deletes
Note over dl,state: 5. FINISH
dl->>state: stamp sweep done
dl->>state: adopt the token from step 1
Snapshot, then tail. The start token is taken before the sweep and adopted only after it. The overlap replays changes that are idempotent and that echo suppression drops; the other order loses everything that changed during the sweep, permanently and silently.
flowchart TD
q{"Remote object?"} -->|yes| ql{"Local copy?"}
q -->|"no (local only)"| qb2{"Has a baseline?"}
ql -->|no| qg{"baseline the remote<br/>still matches?"}
qg -->|no| mat{"lazy, or<br/>-materialize?"}
qg -->|yes| guards2{"sweep complete?<br/>baseline predates it?<br/>remote re-checked?<br/>folder accounted for?<br/>within -max-deletes?"}
guards2 -->|all yes| delr["delete REMOTELY —<br/>deleted here"]
guards2 -->|any no| keepr["KEEP the remote copy"]
mat -->|yes| dl2["materialise:<br/>download or placeholder"]
mat -->|no| defer["count as deferred,<br/>still marked seen"]
ql -->|yes| qb{"remote matches<br/>the baseline?"}
qb -->|no| conf["§6 conflict copy"]
qb -->|"yes / echo match"| qlf{"local copy still<br/>matches it?"}
qlf -->|yes| nop["nothing to do"]
qlf -->|"no fingerprint, or<br/>modified since this<br/>mount opened"| nop
qlf -->|"no, and older than<br/>this mount"| repush["RE-PUSH — a push<br/>was lost (M27 pt 4)"]
qb2 -->|no| push["PUSH — it is new locally"]
qb2 -->|yes| guards{"sweep complete?<br/>baseline predates it?<br/>local unmodified?<br/>within -max-deletes?"}
guards -->|all yes| del["delete"]
guards -->|any no| keep["KEEP — and push back<br/>if it diverged"]
classDef danger fill:#7a1f1f,stroke:#3d0f0f,color:#fff
class del,delr danger
A delete is inferred only from a baseline, never from absence alone. The
baseline is the echo store ("we have synced this content at this path"); the
per-generation seen marks answer "did this sweep observe it?". No baseline means
the path is new, whichever side it is on — which is why the first-ever run
deletes nothing.
Both directions have a delete row. Unlisted remotely, present here and
unmodified is a deletion made elsewhere and removes the local copy. Listed
remotely and still matching the baseline, absent here is a deletion made here —
while drivel was down, or one whose push was abandoned — and removes the remote
copy. That second row was in DESIGN.md's table from M7b and was not implemented
until 2026-10-20: the remote-present rows were left to the pull's apply, which
returns on an echo match before it looks at the local side, so such a path sat
with both sides disagreeing for good. It is goneHere/deleteHere, and it adds
three checks of its own: the remote is re-Stated immediately before the
removal and must still match (an edit made since wins over the deletion); the
local path must still be absent; and a directory is removed only if every object
the sweep saw inside it was removed first, because Store.Remove is recursive.
The four guards on the delete rows are each load-bearing, in both directions:
- Deletes run only after a complete sweep.
- The baseline must predate the sweep, or a file created locally during it looks remotely deleted.
- A local copy that diverged from its baseline is kept and pushed back.
-max-deletesabandons the whole pass rather than trimming it — a huge count means the premise is broken, not that there are 4000 real deletions. A refused pass still counts as completed, so recovery is-resyncand a higher cap.
The sweep has one more pass, and it runs before the local walk above:
pushOwedRepairs re-sends every path the outbound path gave up on (M27). It
cannot be folded into the walk, and the reason is the whole shape of the feature:
the walk is the new locally row and skips any path with a baseline, while an
abandoned push is almost always a rewrite of a file that has one — whose
baseline still describes exactly what the remote holds, so the remote rows match
the echo and correctly do nothing. Before M27 such a file stayed local forever.
A repair that succeeds writes a fresh echo, so the walk then skips it for the
ordinary reason and nothing is sent twice.
M27 part 4 then gave the walk itself a second row, for the pushes no record
could ever name: the engine writes the repair record when it gives up, which
is code that runs, and kill -9 runs none. So repushDiverged compares each
local file against the fingerprint its baseline already carries
(state.Echo.LocalSize/LocalMTime, M18) and re-sends the ones that no longer
match — which also covers a power cut, an OOM kill, a drain that timed out and a
backing file written while the mount was down. Four things bound it: the local
mtime must predate this mount's open (anything newer is the running engine's to
finish, and without the bound a long copy is re-uploaded once per sweep); a
baseline with no fingerprint is counted and skipped rather than guessed at; an
unlisted path belongs to the delete pass; and a path the remote pass could not
settle, or one the repair pass already claimed, is left alone. The converse half
is in the engine: M6's gate 3 records the baseline it verified, without which a
file whose mtime moved but whose content did not is offered for upload on every
mount, forever.
Three implementation notes. The marks are persistent, because an in-memory seen-set would report every page a previous process consumed as remotely deleted. The completion stamp is persistent for a different reason: the periodic schedule measures from it, so someone who mounts for an hour a day still reaches an interval. And the sweep is the only correct place to prune a baseline, because pruning one and inferring a delete are the same decision from the same evidence — TTL and LRU eviction both fail here, silently.
Drive has no recursive "everything under this folder" query, so the two strategies are genuinely different shapes, and neither dominates.
flowchart LR
subgraph flat["flat — one files.list over the account"]
direction TB
f1["page 1 (1000 objects)"] --> f2["page 2"] --> f3["page N"]
f3 --> park["park objects whose parent<br/>is not yet seen; release when<br/>it arrives; drop what is still<br/>parked (outside the mount)"]
end
subgraph scoped["scoped — breadth-first descent"]
direction TB
s1["mount root"] --> s2["list up to 8 folders<br/>concurrently"]
s2 --> s3["queue their subfolders"]
s3 --> s2
s2 -.->|throttled| adj["keep what succeeded,<br/>re-queue only what failed,<br/>halve concurrency"]
adj --> s2
s2 -.->|clean| grow["grow back, ceiling 8"]
end
flat costs ~1 request per 1000 account objects; scoped costs ~1 request per
folder in the mounted subtree. So a folder-dense subtree is cheaper flat, and
auto picks by whether -drive-root names a concrete folder.
Two things about the scoped path are not optional. Under throttling it keeps the listings that succeeded and re-queues only the ones that failed — putting a throttled batch back whole measured 97× the ideal request count at a 20% refusal rate, because at fanout 8 most batches are spoiled and re-issuing all eight feeds the throttle. And the fan-out is a ceiling, not a rate: it halves on a throttled listing and grows back on a clean one, floor 1.
The parking machinery belongs to the flat path alone — the descent is parent-first by construction. Do not unify them. Resume also stops mattering in scoped mode: the frontier is in memory, and a cursor arriving without one simply restarts a descent that is cheap by construction.
sequenceDiagram
autonumber
actor app as Application
participant vfs as fileHandle
participant hyd as Hydrator
participant back as Backing file
participant st as provider.Store
app->>vfs: open(path)
Note over vfs: fetches NOTHING —<br/>open/truncate/rewrite are free
app->>vfs: read(fd, …) or partial write
vfs->>vfs: ensureResident
vfs->>hyd: IsPlaceholder(path)?
hyd->>back: getxattr(marker)
hyd-->>vfs: yes
vfs->>hyd: Hydrate(path) — singleflight
hyd->>st: Get(path)
st-->>hyd: content
hyd->>back: fill in place, drop the marker
alt fetch failed
hyd-->>vfs: error
vfs-->>app: EIO
Note over vfs: never a short read —<br/>zeros served as content<br/>are silent corruption
else
vfs->>back: proceed with the original read/write
vfs-->>app: data
end
Hydrate on first I/O, not on Open. FUSE delivers O_TRUNC as a separate
Setattr unless atomic_o_trunc is negotiated, so Open handles only the atomic
case (dropping the mark) and Setattr handles truncation. The consequence is that
opening a file to overwrite it never downloads the content it is about to discard.
The xattr is authoritative and there is no fallback. IsPlaceholder reads the
marker and only the marker — the state DB caches present ranges, which answers a
different question. No marker means no placeholder record at all, immediately,
which is why -lazy is unsafe on a backing filesystem without user extended
attributes rather than merely fragile.
Conflict copies are always downloaded in full, because the copy is made by drivel rather than materialised from a listing, so nothing would ever hydrate it.
A backend is a separate process (M9, DESIGN.md §2.10) — whether its code is
bundled in this binary and launched by re-executing it (M23) or installed as a
drivel-provider-KIND executable. This is what a mount does to get one, and what
happens when it goes away underneath.
sequenceDiagram
autonumber
participant App as internal/app.Open
participant Loader as plugin.Loader
participant Proc as plugin process supervisor
participant Plug as the backend process
participant Engine as syncengine.Engine
App->>Loader: Register(registry)
Loader->>Loader: take the bundled kinds<br/>scan the search path for the rest
Loader->>Loader: refuse group/world-writable binaries
App->>Proc: Factory(ctx, Params{Config, Log})
Proc->>Plug: fork/exec with a BUILT environment<br/>(this image + "plugin-serve KIND", or the installed file)
Plug-->>Proc: handshake on stdout
Proc->>Plug: Open{config TOML, content broker id}
Plug->>Plug: factory(ctx, Params)<br/>provider.Capabilities(store)
Plug-->>Proc: {capabilities}
Proc-->>App: provider.Store (proxy, declaring those capabilities)
App->>App: AsChangeSource? → start the pull loop, or not
Note over Engine,Plug: normal operation
Engine->>Proc: Put(path, reader)
Proc->>Plug: stream: path, then 256 KiB chunks
Plug-->>Proc: RemoteFile
Note over Plug: the backend crashes
Engine->>Proc: Put(path, reader)
Proc-->>Engine: retryable: "restarting in 1s"
Engine->>Engine: back off (M4 retry, unchanged)
Engine->>Proc: Put(path, reader)
Proc->>Plug: fork/exec, handshake, Open
Proc->>Plug: stream the upload
Plug-->>Engine: RemoteFile
Three things this picture is making a point of:
- The capability answer is taken once, at step 9, and held for the life of the mount — including across a restart. The engine decides at step 11 whether this mount has a pull loop at all, so an answer that changed underneath it would leave a downloader polling a feed that is gone.
- A crash is a retryable error, not a failed push. There is no supervisor goroutine; the engine's existing backoff is what waits, and the next call is what relaunches. The relaunch is rate-limited, and a process that lived at least a minute does not inherit a crash loop's delay.
- The environment at step 5 is built, not inherited. See
plugin/env.go— the point is that a backend authenticates with what its configuration names, so which account a mount uses is a property of the config file rather than of the shell that started it. - Everything from step 6 on is identical for both launch paths, and that is the requirement M23 is built on rather than something it happened to achieve: a bundled backend reaches the host through the same handshake, socket and generated protocol an installed one does, so the only thing the two differ in is what step 5 executes.
stateDiagram-v2
[*] --> Opening
Opening: app.Open — acquire
Opening: resolve backing, open state DB,<br/>build provider, validate cross-mount
Opening --> Serving: all mounts opened
Opening --> Closing: any mount failed<br/>(the ones that came up still drain)
Serving: app.Run — serve
Serving: FUSE server + Engine + Downloader
Serving --> Draining: SIGINT / SIGTERM
Draining: 1. unmount
Draining: 2. wait for the inbound loop<br/>(pull + sweep), bounded
Draining: 3. close(events)
Draining: 4. engine drains in-flight uploads<br/>on a DETACHED context, bounded
Draining --> Closing
Closing: app.Close — release
Closing: state DB, index, provider
Closing --> [*]
The three-phase split is what lets a process that fails to bring up mount 3 of 5
unmount and drain the two that came up. Inside Run the per-mount ordering is
load-bearing: unmount → wait for the inbound loop → close the event channel →
wait for the engine's bounded drain, with the engine on a detached context so
that the Ctrl-C which stopped the mount does not also abort the uploads it is
trying to finish. App aggregates around that; it must never collapse it into one
cancellation.
Both waits are bounded and the bounds differ on purpose. The engine is finishing
uploads the user is waiting for; the pull loop and the sweep have nothing in hand
worth delaying a shutdown for, since an unfinished sweep is resumed and an
unfinished download is retried. What waiting for the inbound loop buys is that
neither of them is still holding the state DB or the backing descriptor when
Close takes those away — before it, a sweep outliving Run met a closed bbolt,
and in in-place mode a download could have written through a /proc/self/fd/N
whose number had been reused.