From 34b38667968424047ad2a2b4efb3c37417626a12 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Tue, 25 Aug 2026 06:16:31 +0200 Subject: [PATCH] Lay the repository out, and give a fresh clone two commands (#13) A clone could build nothing and run nothing. It can now do both, with the two commands written in README.md and no state left by a previous build needed for either. One directory under src/ per thing 0003 says the core owns, so the boundary is in the tree rather than only in a document: server, session, cache, artwork, playback and measurement. Two directories sit beside those six and are not concerns from that record; each says so in its own first paragraph. failure holds the error vocabulary the six map onto, and diagnostics holds the sink a client supplies, which is there because 0009 states a thread rule for it and a rule with no name to attach to is a rule a reader meets nowhere. There is no behaviour. Every type is a name carrying the statement 0009 makes about its kind and nothing else, and each names the issue that owns what it will do. The half that is a guard rather than a layout is 0009's obligation. That record states its reentrancy and thread-safety rules per kind of object because there was no type to attach them to, and says the names arrive with #11 and #13. The statements are attached where a reader meets each type, and tests/thread_statements.rs refuses a change that breaks one: "safe from any thread" is a compile-time bound, checked from outside the crate so that it asks the same question a client asks, and checked on the trait object for a client-supplied store so that dropping the bound from the trait is caught rather than only a bad implementor. Both directions were proven to bite before this landed, and the proofs are in the pull request body. What that bound is worth today is stated in the crate documentation rather than implied: the types hold nothing, so no assertion can fail on these bytes. It bites on the first field. The package is called flowfin-core rather than core. The standard library owns that name, and a local crate carrying it makes every path that means the standard one ambiguous at the first use. One sentence in README.md was a correction rather than an addition. What it said before: > What shared means technically is the first maintainer decision and the plan > states the options with their costs rather than choosing one. Entry 2 of #1 was answered on 2026-08-24 and 0011 records it, so that sentence had stopped being true; it now names the answer and links the record. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- .gitignore | 6 +++ Cargo.lock | 7 ++++ Cargo.toml | 26 ++++++++++++ README.md | 38 ++++++++++++++++- src/artwork/mod.rs | 19 +++++++++ src/cache/mod.rs | 23 +++++++++++ src/diagnostics/mod.rs | 17 ++++++++ src/failure/mod.rs | 13 ++++++ src/lib.rs | 84 ++++++++++++++++++++++++++++++++++++++ src/measurement/mod.rs | 9 ++++ src/playback/mod.rs | 15 +++++++ src/server/mod.rs | 23 +++++++++++ src/session/mod.rs | 35 ++++++++++++++++ tests/thread_statements.rs | 68 ++++++++++++++++++++++++++++++ 14 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 src/artwork/mod.rs create mode 100644 src/cache/mod.rs create mode 100644 src/diagnostics/mod.rs create mode 100644 src/failure/mod.rs create mode 100644 src/lib.rs create mode 100644 src/measurement/mod.rs create mode 100644 src/playback/mod.rs create mode 100644 src/server/mod.rs create mode 100644 src/session/mod.rs create mode 100644 tests/thread_statements.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ffbafc5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# What a build leaves behind, and nothing else. +# +# One entry rather than a list of patterns somebody adds to: the build tool puts +# every artefact under one directory, and a rule per artefact kind is a rule that +# is missing the day a new kind appears. +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d854166 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "flowfin-core" +version = "0.0.0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..7909009 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +# The package is not called `core`. That name is taken by the standard library's +# own crate, and a local crate carrying it makes every path that means the +# standard one ambiguous at the first `use`. +name = "flowfin-core" +version = "0.0.0" +edition = "2024" +license = "AGPL-3.0-or-later" +description = "The shared core every Flowfin client uses: reaching a server, holding a session, caching, artwork, playback position and measurement." +repository = "https://github.com/Flowfin/core" +publish = false + +[lib] +# The library target keeps the package name with the separator the language +# requires, so a client writes `use flowfin_core::...`. +name = "flowfin_core" + +[dependencies] +# Empty on purpose. `docs/decisions/0103-what-admits-a-dependency-and-what-is-refused.md` +# is the rule that admits an entry here, and every entry carries the clause that +# admitted it and what would retire it on the line above it. + +[dev-dependencies] +# Empty on purpose, and judged by the same record: the relaxation there is on +# what a dependency has to be worth, not on the licence set or the four +# behaviours. diff --git a/README.md b/README.md index 5916f3d..74c23e0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,47 @@ # core -Every Flowfin client needs the same things and none of them should write those twice: talking to a Jellyfin server, holding a session, caching what was fetched, decoding artwork, tracking playback position, and measuring whether the speed budget was met. Eleven clients written independently drift in what they cache, in when they give up on a slow server, and in what they call fast. The speed budget is written as numbers a build can miss, and a number nothing measures is a wish, so this is where those numbers are instrumented. What shared means technically is the first maintainer decision and the plan states the options with their costs rather than choosing one. The core draws nothing: a core that knows about widgets stops being shared the first time two platforms disagree about a list. +Every Flowfin client needs the same things and none of them should write those twice: talking to a Jellyfin server, holding a session, caching what was fetched, decoding artwork, tracking playback position, and measuring whether the speed budget was met. Eleven clients written independently drift in what they cache, in when they give up on a slow server, and in what they call fast. The speed budget is written as numbers a build can miss, and a number nothing measures is a wish, so this is where those numbers are instrumented. What shared means technically is decided: one Rust library reaching each client through a foreign function interface generated per platform, recorded with its costs in [0011](docs/decisions/0011-the-language-the-toolchain-and-the-binding-layer.md). The core draws nothing: a core that knows about widgets stops being shared the first time two platforms disagree about a list. Planning happens on the issue tracker first. Every decision that shapes the architecture is written down there with its reasons before the code that depends on it exists. +## Building it + +A fresh clone needs a Rust toolchain and nothing else. `cargo`, the formatter and +the analyser all arrive with it, and there is no dependency to fetch: the manifest +declares none, and what may ever be added to it is +[0103](docs/decisions/0103-what-admits-a-dependency-and-what-is-refused.md). +Which version is pinned is not settled yet and is #14; until it is, a current +stable toolchain builds this. + +Two commands, and they are the two the gate runs rather than variants of them: + + cargo build --locked --all-targets + cargo test --locked + +`--locked` is there in both so that a build which would rewrite `Cargo.lock` +fails instead of proceeding quietly. `--all-targets` is there so that the first +command builds the tests as well as the library, which is what makes it a build +of everything rather than of half of it. + +## How the tree is arranged + +One directory under `src/` per thing +[0003](docs/decisions/0003-what-the-core-does-not-do.md) says the core owns, so +that the boundary is visible in the tree and not only in a document: +`src/server/`, `src/session/`, `src/cache/`, `src/artwork/`, `src/playback/` and +`src/measurement/`. + +Two directories beside those six are not concerns from that record and say so in +their own first paragraph. `src/failure/` holds the error vocabulary the six map +onto, and `src/diagnostics/` holds the sink a client supplies. + +There is no behaviour in any of them yet. What each type is, and the statement +about which thread a client may call it from, is written where a reader meets the +type; `tests/thread_statements.rs` is what refuses a change that breaks one of +those statements. + See [NOTICE.md](NOTICE.md) for the intended-use notice. See [SECURITY.md](SECURITY.md) for how to report a security problem, what diff --git a/src/artwork/mod.rs b/src/artwork/mod.rs new file mode 100644 index 0000000..5f06ec7 --- /dev/null +++ b/src/artwork/mod.rs @@ -0,0 +1,19 @@ +//! Fetching and decoding artwork. +//! +//! 0003 keeps image decoding inside the core and stops at the bitmap: turning +//! bytes into pixels is a parse of untrusted input that arrived over a network, +//! and putting that bitmap on a surface is the client's. The records are 0050, +//! 0053, 0054 and 0055, and the issues are #49 through #55. + +/// Pixels the core produced from bytes a server sent. +/// +/// Thread safety, from 0009: a decoded image is immutable, and its bytes belong +/// to the caller from the moment they are handed over. The core does not read +/// them again. +/// +/// What formats are decoded at all, and how the rest are refused by name, is +/// 0055 and #55. The bound checked before a decode is 0050 and #50. +#[derive(Debug)] +pub struct DecodedImage { + _private: (), +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..a93431a --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,23 @@ +//! Caching what was fetched. +//! +//! 0003 puts the keys, the bound, the eviction, the age of an entry and whether +//! a stale entry may be served inside the core, and puts the location of storage +//! outside it. The records are 0006, 0040, 0041, 0042, 0043, 0046, 0047 and +//! 0105, and the issues are #40 through #48 and #105. +//! +//! 0041 requires a cryptographic digest for a cache key, 0011 measures that the +//! toolchain offers none, and 0103 is the rule that decides whether one may be +//! taken as a dependency. Nothing here is written against a digest that does not +//! exist yet. + +/// The place a client lets the core put bytes. +/// +/// The core is told where to write and never asks the platform, which is 0003's +/// sentence and 0040's record. #40 is the issue that decides what this asks of a +/// client. +/// +/// Thread safety, from 0009: may be called from either lane and concurrently, +/// including for two entries at once. A client that assumed single-threaded +/// access would corrupt its own storage rather than producing a failure the core +/// could report, which is why the statement is here rather than left implied. +pub trait ByteStore: Send + Sync {} diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs new file mode 100644 index 0000000..bdf23a0 --- /dev/null +++ b/src/diagnostics/mod.rs @@ -0,0 +1,17 @@ +//! What the core tells a client about itself. +//! +//! This is not one of the six things 0003 names either. It is here because 0009 +//! states a thread rule for the sink a client supplies, and a rule with no name +//! to attach to is a rule a reader meets nowhere. The record is 0100 and the +//! issue is #100. What may leave through an event is 0071 and #71. + +/// The place a client receives the core's diagnostic events. +/// +/// Thread safety, from 0009: may be called from any lane, at any time, and +/// concurrently. It must be safe for that, it must not block, and it must not +/// call back into the core. The last of the three is the deadlock, so the +/// interface forbids it rather than documenting it. +/// +/// What an event carries, and which fields may appear in one at all, is 0071 and +/// #71. Nothing here decides either. +pub trait DiagnosticsSink: Send + Sync {} diff --git a/src/failure/mod.rs b/src/failure/mod.rs new file mode 100644 index 0000000..4c835d9 --- /dev/null +++ b/src/failure/mod.rs @@ -0,0 +1,13 @@ +//! The error vocabulary every client shares. +//! +//! This is not one of the six things 0003 names. That record places the mapping +//! of every failure onto one vocabulary inside "reaching a server", and every +//! other module here produces failures too, so the vocabulary sits beside the six +//! rather than inside one of them. The records are 0004 and 0037, and the issues +//! are #4 and #37. +//! +//! 0037 requires one point at which a failure becomes a kind, with nothing +//! falling through to a default. On the chosen means that is a refusal rather +//! than a convention: a value of the set is built inside this module and the +//! compiler refuses construction anywhere else. 0011 carries the measurement that +//! says so. diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..957bf77 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,84 @@ +//! The shared core every Flowfin client uses. +//! +//! # What is here, and why it is arranged this way +//! +//! `docs/decisions/0003-what-the-core-does-not-do.md` draws the boundary: the +//! core owns everything between a server address and a decoded byte, and it owns +//! nothing a person can see. That record names six things the core owns, and +//! this crate has one module for each of them, so the boundary is visible in the +//! tree rather than only in a document. +//! +//! | module | the thing 0003 says the core owns | +//! |-------------------|-----------------------------------------------| +//! | [`server`] | reaching a server | +//! | [`session`] | holding a session | +//! | [`cache`] | caching what was fetched | +//! | [`artwork`] | fetching and decoding artwork | +//! | [`playback`] | tracking playback position | +//! | [`measurement`] | producing measurements | +//! +//! Two more modules are here and neither is one of the six. [`failure`] holds +//! the error vocabulary the other six map onto, which 0003 places inside +//! "reaching a server" and which every one of them uses; splitting it out is a +//! layout choice rather than a boundary claim. [`diagnostics`] holds the sink a +//! client supplies, because +//! `docs/decisions/0009-the-concurrency-model.md` states a thread rule for that +//! sink and the rule has to be attached to a name a reader meets. +//! +//! # What is deliberately not here +//! +//! Behaviour. Every type below is a name with the statement 0009 makes about its +//! kind, and nothing else. The interfaces, the fields and what any of it does +//! belong to the issues named beside each one, and a layout that decided them +//! would be deciding them in the file that was supposed to hold them. +//! +//! # The thread statements are checked rather than written +//! +//! 0009 states its reentrancy and thread-safety rules per kind of object and +//! says each type carries the statement for its kind where a reader will meet +//! it. A doc comment is where a reader meets it; the assertions at the bottom of +//! this file are what refuses a change that breaks it. "Safe from any thread" is +//! `Send + Sync` here, and a field that is not thread-safe stops the crate +//! compiling rather than being caught in review. +//! +//! What that bound is worth today is stated rather than implied: the types below +//! hold nothing, so no assertion can fail on the bytes in this tree. It bites on +//! the first field, which is the change these assertions exist for. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +pub mod artwork; +pub mod cache; +pub mod diagnostics; +pub mod failure; +pub mod measurement; +pub mod playback; +pub mod server; +pub mod session; + +/// A running core. +/// +/// Thread safety, from 0009: safe from any thread, always, including while it is +/// being stopped. It is the only object with no conditions on it. +/// +/// What creating and stopping one means, and what happens to a host that +/// suspends it, is #115. This type carries no method for either. +#[derive(Debug)] +pub struct Core { + _private: (), +} + +/// Asserts at compile time that a type is safe to use from any thread. +/// +/// This is the whole mechanism behind every "safe from any thread" statement in +/// this crate. It is a function rather than a comment so that the compiler is +/// what refuses the violation. +const fn any_thread() {} + +const _: () = { + any_thread::(); + any_thread::(); + any_thread::(); + any_thread::(); +}; diff --git a/src/measurement/mod.rs b/src/measurement/mod.rs new file mode 100644 index 0000000..299d054 --- /dev/null +++ b/src/measurement/mod.rs @@ -0,0 +1,9 @@ +//! Producing measurements. +//! +//! 0003 puts named spans, their values, the spread across repeated runs, and a +//! statement of what a run did not measure inside the core. The records are +//! 0008, 0061 and 0064, and the issues are #61 through #67. +//! +//! 0061 refused a tracing library and 0064 names the two numbers the core does +//! not report. Both are reasons this module exists as the core's own facility +//! rather than as a seam onto somebody else's. diff --git a/src/playback/mod.rs b/src/playback/mod.rs new file mode 100644 index 0000000..28db7c9 --- /dev/null +++ b/src/playback/mod.rs @@ -0,0 +1,15 @@ +//! Tracking playback position. +//! +//! 0003 puts the unit, the precision, the reporting cadence, what happens to a +//! position recorded while the server was gone, and what counts as watched +//! inside the core. The records are 0056, 0057, 0058, 0060 and 0111, and the +//! issues are #56 through #60 and #111. +//! +//! Video decoding is outside the core, for the reason 0112 records. The core +//! stops at the handover in #111. +//! +//! 0056 fixes the unit a position is expressed in against the server rather than +//! against whatever duration type the runtime offers, and 0011 measures what that +//! type actually is on the chosen toolchain: unsigned, and in nanoseconds. The +//! conversion at the boundary is 0056's, and nothing here adopts the runtime type +//! as the wire unit. diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..f1c901e --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,23 @@ +//! Reaching a server. +//! +//! 0003 puts address parsing, the transport, timeouts, certificate validation, +//! retry and the mapping of every failure onto one error vocabulary inside the +//! core, and this is where they live. The records that decide them are 0027, +//! 0028, 0029, 0037, 0038 and 0069, and the issues that build them are #27 +//! through #39. +//! +//! The mapping point 0037 requires is one place, and the type it produces lives +//! in [`crate::failure`] rather than here, so that a value of the failure set +//! cannot be built anywhere else. + +/// An answer the core has already received and handed back. +/// +/// Thread safety, from 0009: a query result is immutable once it has been handed +/// back. There is no shared mutable state to protect, and the core keeps no +/// reference through which it could change one. +/// +/// What a client can ask for, and what comes back, is #39. +#[derive(Debug)] +pub struct QueryResult { + _private: (), +} diff --git a/src/session/mod.rs b/src/session/mod.rs new file mode 100644 index 0000000..2e43b3f --- /dev/null +++ b/src/session/mod.rs @@ -0,0 +1,35 @@ +//! Holding a session. +//! +//! 0003 puts acquiring a session, renewing it, holding more than one at a time, +//! and handing the secret to a store the client supplies inside the core. The +//! records are 0005, 0030, 0031, 0032, 0033, 0034, 0036 and 0114, and the issues +//! are #30 through #36 and #114. + +/// One signed-in session against one server. +/// +/// Thread safety, from 0009: safe from any thread. Calling on a session while +/// another thread signs it out is defined rather than racing: the call either +/// goes out under a valid token or fails with the signed-out outcome, and never +/// goes out under a token that has been discarded. +/// +/// Signing out and holding several at once is #114. +#[derive(Debug)] +pub struct Session { + _private: (), +} + +/// The place a client keeps a session secret. +/// +/// The core never chooses where a secret is kept. 0033 is the record and #33 is +/// the issue that decides what this asks of a client. +/// +/// Thread safety, from 0009: called from the waiting lane only, and never +/// concurrently for one session, so a client may implement it without locking. +/// This is the deliberate opposite of [`crate::cache::ByteStore`], and the reason +/// is that a keychain call is rare and a platform keychain is the place a client +/// is most likely to write something naive. +/// +/// The `Send + Sync` bound is here because the lane that calls it is not the +/// thread that supplied it. It is not a licence to call it concurrently, and +/// 0009's sentence above is the rule. +pub trait SecretStore: Send + Sync {} diff --git a/tests/thread_statements.rs b/tests/thread_statements.rs new file mode 100644 index 0000000..a400137 --- /dev/null +++ b/tests/thread_statements.rs @@ -0,0 +1,68 @@ +//! Every "safe from any thread" statement 0009 makes, checked from outside the +//! crate. +//! +//! These are compile-time checks in the body of a test rather than assertions a +//! run evaluates, and that is worth stating plainly: a failure here is a build +//! that does not compile, not a red line in the test output. What the run adds is +//! a named result per kind, so that a reader of the output can see which of +//! 0009's statements are covered rather than counting them in a file. +//! +//! Checking from a test crate rather than inside the library is deliberate. It +//! asks the same question a client asks: the type is reachable from outside, and +//! it carries the bound out there too. +//! +//! What this file does not check. 0009 makes statements no bound can carry: that +//! the secret store is called from one lane only and never concurrently for one +//! session, that a sink must not block, and that a sink must not call back into +//! the core. Those are properties of the caller rather than of the type, and the +//! detector in #117 is where they are meant to be observed. Nothing here covers +//! them. + +use flowfin_core::Core; +use flowfin_core::artwork::DecodedImage; +use flowfin_core::cache::ByteStore; +use flowfin_core::diagnostics::DiagnosticsSink; +use flowfin_core::server::QueryResult; +use flowfin_core::session::{SecretStore, Session}; + +/// Compiles only for a type that is safe to use from any thread. +/// +/// `?Sized` is what lets the same function ask the question of a trait object, +/// which is how the bound on a client-supplied trait is checked rather than the +/// bound on one particular implementor of it. +const fn any_thread() {} + +#[test] +fn the_core_handle_is_safe_from_any_thread() { + const _: () = any_thread::(); +} + +#[test] +fn a_session_handle_is_safe_from_any_thread() { + const _: () = any_thread::(); +} + +#[test] +fn a_query_result_is_safe_from_any_thread() { + const _: () = any_thread::(); +} + +#[test] +fn a_decoded_image_is_safe_from_any_thread() { + const _: () = any_thread::(); +} + +#[test] +fn the_byte_store_a_client_supplies_is_safe_from_any_thread() { + const _: () = any_thread::(); +} + +#[test] +fn the_secret_store_a_client_supplies_is_safe_from_any_thread() { + const _: () = any_thread::(); +} + +#[test] +fn the_diagnostics_sink_a_client_supplies_is_safe_from_any_thread() { + const _: () = any_thread::(); +}