Skip to content

Session Module

The notebook front door. dvma.launch(settings) starts the whole logger — acquisition bridge, native compute engine, session journal and embedded web UI — on a background thread inside the calling kernel, and returns a Session handle for pulling data out and pushing data back. It is the successor to the removed dvma.Logger.

The server process, not the browser tab, owns the session document, so closing the tab loses nothing: reopen the URL and the app offers the session back.

Launching a Session

launch

launch(settings=None, open_browser=True, port=0, ui_dir=None, session_dir=None, recover=True)

Start a pydvma session and return its :class:Session handle.

Runs the same server as pydvma-serve — acquisition bridge, native /engine compute host, session journal, embedded UI — on a daemon thread with its own event loop inside the calling process, so this works unchanged from a plain script and from inside Jupyter (whose kernel already runs an asyncio loop of its own).

The default port=0 takes an ephemeral port, so several sessions can run side by side; read the one actually bound from :attr:Session.url. Printing that URL is how a session announces itself, matching pydvma-serve's startup line.

Raises ImportError naming pip install pydvma[serve] when the optional server dependencies are absent — the retired dvma.Logger tombstone sends people straight here, and a base install would otherwise fail deep inside the background thread with an opaque RuntimeError about the server not starting. Raises RuntimeError if the server does not bind within :data:STARTUP_TIMEOUT_S — chained to the underlying error (an OSError when an explicit port is already taken) when there was one.

Parameters:

  • settings (MySettings or None, default: None ) –

    acquisition settings the app opens with. Prefills Setup via /config (see :func:_settings_to_config_json) and supplies the default acquisition driver; None prefills nothing and leaves the driver at 'auto' — a capture that names no device then records from the OS default input soundcard when there is one, else the mock generator (see :func:pydvma.serve.resolve_default_device).

  • open_browser (bool, default: True ) –

    open the app in a browser tab (default True). Pass False for a headless or scripted launch — the URL is still printed and on :attr:Session.url.

  • port (int, default: 0 ) –

    TCP port to bind, or 0 (default) for an ephemeral one.

  • ui_dir (str or Path or None, default: None ) –

    built UI directory to serve. None (default) resolves exactly as pydvma-serve does — the dev checkout's webui/dist if present, else the copy packaged in the wheel, else no UI at all (the bridge still serves, with a help page).

  • session_dir (Path or str or None, default: None ) –

    where the journal's spill file lives and previous-run sessions are recovered from. Passed through to :class:~pydvma.serve.BridgeServer; None means the system temp dir. Tests and advanced users point this at a directory they control.

  • recover (bool, default: True ) –

    offer a previous run's spill file for recovery in the app (default True). Passed through to :class:~pydvma.serve.BridgeServer; False skips the startup scan entirely.

The Session Handle

Session

Bases: object

A running pydvma session: the served app plus its data.

Returned by :func:launch; not constructed directly. Usable as a context manager, which closes the server on exit.

Parameters:

  • server (BridgeServer) –

    the running server, whose :attr:~pydvma.serve.BridgeServer.journal holds the authoritative session document.

  • thread (Thread) –

    the daemon thread running it.

  • loop (AbstractEventLoop) –

    that thread's event loop.

  • url (str) –

    where the app is served, with a trailing slash.

Attributes:

  • url (str) –

    where the app is served (e.g. 'http://127.0.0.1:8760/') — hand it to a browser on another window, or re-open it after closing the tab; the session document survives (that is what the journal is for).

Attributes

_server instance-attribute
_server = server
_thread instance-attribute
_thread = thread
_loop instance-attribute
_loop = loop
url instance-attribute
url = url
_closed instance-attribute
_closed = False
_close_lock instance-attribute
_close_lock = threading.Lock()
data property
data

The session's data as a fresh DataSet (read-only view).

Materialised from the journal on EVERY access: the posted session document, with any captures logged since that post merged in by :func:_merge_dataset. Nothing here is shared with the app or the engine — mutating what you pull changes nothing until you :meth:push it back, and the ids carried through the container round-trip are what make that push land in place.

An empty DataSet when the session has no document and no pending captures yet. Still readable after :meth:close — pulling your data out of a session you have finished with is the point of the explicit handoff, and the journal outlives the server thread that fed it.

Methods:

__init__
__init__(server, thread, loop, url)
__repr__
__repr__()

Notebook-facing summary: URL, driver, and open/closed.

_snapshot
_snapshot()

Return (dataset, generation) — the journal, materialised.

The generation belongs to the same read as the data, so a writer can hand it back to :meth:pydvma.journal.SessionJournal.set_doc as expect_generation and be refused if anything changed in between. :attr:data is this without the bookkeeping.

push
push(data)

Hand data to the session; connected apps offer to reload.

Merges into the CURRENT session data (:attr:data) rather than replacing it, by :func:_merge_dataset's id rule — so pushing back something you pulled and edited updates that item in place, while genuinely new items append.

The merge replaces a matched item WHOLE, so what you push is what is stored — including the app-side display state a pulled item carries invisibly (channel labels, per-set analysis settings: the manifest keys pydvma.container stashes as _container_extra rather than dropping). Pull → modify → push therefore preserves it; pushing a NEWLY built item in place of a stored one legitimately replaces that state with nothing, and the app re-seeds its defaults on reload.

The read-merge-write cycle is guarded by the journal's generation counter, so nothing is lost to a race: if a capture lands, or the app's autosave posts, between the read and the write, the post is REFUSED and this retries from a fresh read (up to :data:PUSH_MAX_ATTEMPTS times). Two concurrent pushes therefore serialise — one wins, the other re-merges on top of the winner's document — instead of one silently overwriting the other.

Raises TypeError if data is not a DataSet and is not something DataSet.add_to_dataset recognises (which would otherwise post an unchanged document and look like success), RuntimeError if the session is closed, and RuntimeError if the journal kept changing under every attempt.

Parameters:

  • data (pydvma.datastructure.DataSet or a single data item) –

    what to hand over. Anything that is not already a DataSet is wrapped in one, so a lone :class:~pydvma.datastructure.TimeData (or any other item DataSet.add_to_dataset accepts) works directly.

close
close()

Stop the server and its thread. Idempotent and thread-safe.

A second call — from this thread or another — is a clean no-op rather than a second stop injected into a shutdown already in progress. Prints a WARNING if the thread has not finished within :data:SHUTDOWN_TIMEOUT_S, in which case the port may still be bound; the thread is a daemon, so it cannot keep the interpreter alive either way.

:attr:data keeps working afterwards (the journal is plain memory); :meth:push does not, since there is no longer an app to notify.

__enter__
__enter__()

Return self, so with launch(...) as session: works.

__exit__
__exit__(exc_type, exc, tb)

Close the session on context-manager exit.

Returns None, so an exception raised inside the with block propagates after the server is stopped.

The Session Journal

The server-side store behind Session.data and Session.push. Held by pydvma.serve.BridgeServer; the browser app reads and writes it over the /engine socket. Normally used through Session rather than directly.

SessionJournal

Bases: object

In-memory session document + pending captures + listeners.

Parameters:

  • spill_path (Path or str or None, default: None ) –

    file to mirror the current document into on every update (best-effort; errors are swallowed). None disables spilling; it can be set later with :meth:set_spill_path (a server on an ephemeral port only knows its identity after binding).

Attributes

_lock instance-attribute
_lock = threading.Lock()
_spill_lock instance-attribute
_spill_lock = threading.Lock()
_doc instance-attribute
_doc = None
_captures instance-attribute
_captures = []
_listeners instance-attribute
_listeners = []
_spill_path instance-attribute
_spill_path = spill_path
_recovered instance-attribute
_recovered = None
_recovered_path instance-attribute
_recovered_path = None
_generation instance-attribute
_generation = 0
_spill_failures instance-attribute
_spill_failures = 0
generation property
generation

How many writes this journal has accepted (read-only).

Starts at 0 and increments on every accepted :meth:set_doc and every :meth:add_capture — see the module docstring. A refused set_doc does not increment it. Read it through :meth:state when the value must match the data you read; this property is for tests and diagnostics, where a bare counter is enough.

spill_path property
spill_path

Where the document is mirrored, or None (read-only).

spill_failures property
spill_failures

How many spills failed end-to-end and were swallowed (read-only).

Counts every :meth:_spill that had a document and a path but could not land it on disk — temp-file creation, the write, or an os.replace still failing after :func:_replace_with_retry's whole ladder. A non-zero count means the spill file may be STALE relative to :meth:state; the next successful spill overwrites it, but nothing rewinds this counter. Disabled spilling (no path) and empty journals do not count — those are no-ops, not failures.

Methods:

__init__
__init__(spill_path=None)
set_doc
set_doc(doc_bytes, notify=False, expect_generation=None)

Replace the session document (and clear pending captures).

Only the captures this document PROVABLY contains are cleared: the document's unique_id set is read from its manifest (:func:pydvma.container.manifest_ids) and a pending capture is dropped only when its own ids are a subset of it, so a capture that landed after the poster serialised its document — or one belonging to a different tab — survives the post and is still offered on the next :meth:state. A capture with no readable ids is cleared by any post (see the module docstring's clears-pending contract).

Returns True when the document was written, and False only when an expect_generation was supplied and no longer matches — in which case NOTHING happens: the document is untouched, pending captures are untouched, no spill is written and no listener fires. Callers that pass expect_generation must handle the False by re-reading :meth:state and re-merging (see :meth:pydvma.session.Session.push).

Parameters:

  • doc_bytes (bytes, bytearray, or memoryview) –

    the full session document (the same bytes written to a .dvma file).

  • notify (bool, default: False ) –

    also call every registered listener after the replace. Used by :meth:pydvma.session.Session.push so connected apps reload; the app's own autosave posts use the default False (silent — the app already has what it just posted).

  • expect_generation (int or None, default: None ) –

    the :attr:generation this document was built from. When given, the post is refused unless the journal is still at that generation, so a capture or another writer's post that landed in between can never be silently overwritten (see the module docstring). None (the default) posts unconditionally — right for a writer that owns the whole document already.

add_capture
add_capture(dvma_bytes)

Register one capture's .dvma bytes, pending until a document containing it is posted (module docstring's contract).

The capture's identity — its TimeData unique_ids, read from the manifest by :func:pydvma.container.manifest_ids and stored beside the bytes — is what a later :meth:set_doc matches against to decide whether that document already holds this capture. Reading it here, once, keeps the cost off every subsequent post.

This does NOT touch the spill file — the spill mirrors only the posted document (see the module docstring), so a capture registered here is absent from the crash artifact until the app's next autosave lands.

If appending pushes the pending list's total size over :data:PENDING_CAPTURES_MAX_BYTES, the OLDEST pending entries are evicted (silently — there is no error, no truncation signal, just fewer captures on the next :meth:state) until the total is back under the cap or the list is empty. Eviction is strictly oldest-first with no special case for the entry just appended: a single capture that alone exceeds the whole budget is evicted too, leaving the list empty rather than holding one capture over cap. See the module docstring's "Pending-captures budget" paragraph — this only matters when nothing ever posts a clearing document.

Parameters:

  • dvma_bytes (bytes, bytearray, or memoryview) –

    one capture's full .dvma bytes.

state
state()

Current (doc_bytes_or_None, [capture_bytes, ...], generation).

The list is a fresh list of the capture BYTES (the per-capture id sets kept alongside them are the journal's own bookkeeping and never leave it) — mutating it never touches the journal. The document is returned by reference, but bytes is immutable so that is equivalent to a copy for callers. The generation describes THIS snapshot: hand it back as :meth:set_doc's expect_generation to post an update that refuses to clobber anything that landed since.

add_listener
add_listener(cb)

Register a zero-arg callable invoked on notify updates.

Returns an unsubscribe callable. Listener exceptions are swallowed (one broken listener must not silence the rest). An update already in flight when unsubscribe() returns may still call cb once more — :meth:set_doc snapshots the listener list before releasing the lock, so a race between an in-progress notify and a concurrent unsubscribe is possible; consumers must tolerate one extra call.

Parameters:

  • cb (callable) –

    zero-argument callable to invoke on every notify=True update, until unsubscribed.

set_spill_path
set_spill_path(path)

Set (or move) the spill target after construction — BridgeServer only knows its real port after binding.

Parameters:

  • path (Path or str or None) –

    file to mirror the current document into on every update, or None to disable spilling.

adopt_recovered
adopt_recovered(path)

Read a PREVIOUS run's spill file into memory as a recovery offer. Reading now — not at offer time — makes a later overwrite of the same path harmless. A missing or unreadable file is a no-op, as is an empty one.

Parameters:

  • path (Path or str) –

    the previous run's spill file to read.

Returns:

  • adopted ( bool ) –

    True if path was actually adopted (readable and non-empty), False on the no-op paths above. Callers that try several candidates in order (see :func:pydvma.serve._adopt_previous_session) use this to know whether to keep trying the next one, rather than checking :meth:recovered for a change.

recovered
recovered()

The adopted previous-run document bytes, or None.

discard_recovered
discard_recovered()

Drop the recovery offer and delete its file (the app's Dismiss).

Skips the delete when the recovered file IS the current spill target — a same-port restart adopts what is now its own live spill file, and deleting it would remove the live mirror, not just the stale offer. The comparison is normalised through os.path.abspath on both sides so a str spill path and a pathlib.Path recovered path (or vice versa) still compare equal when they name the same file. Best-effort on the delete; idempotent.

_spill
_spill()

Mirror the current doc to spill_path, atomically.

Serialised against concurrent calls via _spill_lock (held for the whole body) so overlapping spills can never interleave their writes; each call re-reads the CURRENT document under the lock, so whichever spill runs last always writes the latest doc. The write itself goes to a temporary file in the same directory, then os.replaces over spill_path — the same tempfile-then-rename idiom as :func:pydvma.container.save — so a crash mid-write can never truncate or tear the previous good copy. The replace runs through :func:_replace_with_retry because on Windows an external scanner transiently holding the temp file or the target makes it fail spuriously. Best-effort beyond that: any OSError (including the temp file's own creation, e.g. a missing directory) is swallowed after cleaning up any partial temp file, and counted in :attr:spill_failures so a stale spill is observable.