Skip to content

feat: Support Multiple session stores #277

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 38 commits into
base: master
Choose a base branch
from

Conversation

ElijahAhianyo
Copy link
Contributor

@ElijahAhianyo ElijahAhianyo commented Apr 6, 2025

This Pr lays the foundation for supporting multiple session stores. Currently, there are 4 supported store types:
Memory, Cache, File and DB(implementation to be added in a follow up PR).

Memory

This is the default store which stores the session data in a thread-safe hashmap. It is suitable for development environments

Toml Example

...
[middlewares.session.store]
type = "memory"

Config Example

use cot::config::{MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, SessionStoreTypeConfig};
...
struct FooProject;

impl Project for FooProject {

    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
        Ok(ProjectConfig::builder()
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .store(SessionStoreConfig::builder()
                            .store_type(SessionStoreTypeConfig::Memory)
                            .build()
                        )
                        .build())
                    .build(),
            )
            .build()
        )
    }
}

File Store

The file store persists sessions in a specified directory as files on a file system.

Toml Example

...
[middlewares.session.store]
type = "file"
path = "/path/to/session/storage"

Config Example

use std::path::PathBuf;
use cot::config::{MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, SessionStoreTypeConfig};

...
struct FooProject;

impl Project for FooProject {

    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
        Ok(ProjectConfig::builder()
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .store(SessionStoreConfig::builder()
                            .store_type(SessionStoreTypeConfig::File{ path : PathBuf::from("/path/to/dir")})
                            .build()
                        )
                        .build())
                    .build(),
            )
            .build()
        )
    }
}

Cache Store

The cache store uses a configured cache backed to persist data. This PR ships with support for a Redis backend. Support for other cache backends will be added in follow up PRs.
The cache store is gated behind the cache feature, while the redis implementation is gated behind the redis feature. To use the redis cache store, you will have to enable both.

Toml Example

...
[middlewares.session.store]
type = "cache"
uri = "redis://localhost:6379"

Config Example

use cot::config::{CacheUrl, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, SessionStoreTypeConfig};

...
struct FooProject;

impl Project for FooProject {

    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {
        Ok(ProjectConfig::builder()
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .store(SessionStoreConfig::builder()
                            .store_type(SessionStoreTypeConfig::Cache{ uri : CacheUri::from("redis://localhost:6379")})
                            .build()
                        )
                        .build())
                    .build(),
            )
            .build()
        )
    }
}

DB store

The DB store uses Cot's ORM to persist session data, Its implementation details will be added in a follow up PR.

@github-actions github-actions bot added the C-lib Crate: cot (main library crate) label Apr 6, 2025
Copy link

codecov bot commented Apr 6, 2025

Codecov Report

Attention: Patch coverage is 89.50617% with 68 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
cot/src/session/store/file.rs 86.12% 13 Missing and 16 partials ⚠️
cot/src/session/store/redis.rs 84.31% 10 Missing and 14 partials ⚠️
cot/src/config.rs 91.97% 13 Missing ⚠️
cot/src/middleware.rs 90.90% 2 Missing ⚠️
Flag Coverage Δ
rust 88.31% <89.50%> (+0.11%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
cot/src/session.rs 82.35% <ø> (ø)
cot/src/session/store.rs 100.00% <100.00%> (ø)
cot/src/session/store/memory.rs 100.00% <100.00%> (ø)
cot/src/middleware.rs 82.60% <90.90%> (+0.33%) ⬆️
cot/src/config.rs 94.34% <91.97%> (+2.99%) ⬆️
cot/src/session/store/redis.rs 84.31% <84.31%> (ø)
cot/src/session/store/file.rs 86.12% <86.12%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ElijahAhianyo ElijahAhianyo force-pushed the elijah/session-store-dynamic branch from f8e50cd to 3c6dbd6 Compare April 24, 2025 13:33
@ElijahAhianyo
Copy link
Contributor Author

@m4tx @seqre this PR is not fully ready, but I'd love some initial feedback if this design is heading in the right direction or makes any sense to you at all.

Example usages

No session storage specified(using the admin example)

when the session_store is not provided, it defaults to the MemoryStore in the config

struct AdminProject;

impl Project for AdminProject {
   ...
    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {

        Ok(ProjectConfig::builder()
            .debug(true)
            .database(
                DatabaseConfig::builder()
                    .url("sqlite://db.sqlite3?mode=rwc")
                    .build(),
            )
            .auth_backend(AuthBackendConfig::Database)
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .build())
                    .build(),
            )
            .build())
    }

    ...
}

Session storage provided using MemoryStore provided by Cot

struct AdminProject;

impl Project for AdminProject {
   ...
    fn config(&self, _config_name: &str) -> cot::Result<ProjectConfig> {

        Ok(ProjectConfig::builder()
            .debug(true)
            .database(
                DatabaseConfig::builder()
                    .url("sqlite://db.sqlite3?mode=rwc")
                    .build(),
            )
            .auth_backend(AuthBackendConfig::Database)
            .middlewares(
                MiddlewareConfig::builder()
                    .session(SessionMiddlewareConfig::builder()
                        .secure(false)
                        .session_store(Arc::new(MemoryStore::default()))
                        .build())
                    .build(),
            )
            .build())
    }

    ...
}

(Also haven't played around custom SessionStores yet)

@m4tx
Copy link
Member

m4tx commented Apr 25, 2025

@ElijahAhianyo I think we should do this similarly to the Auth backend, i.e. the ProjectConfig should only include a serializable enum of possible choices for the Session store (Memory Store, ORM, Redis, etc., possibly with some inner configs as well), and the Bootstrapper should initialize a proper Session Store backend by calling a method (called session_store?) on the Project trait. The idea is that ProjectConfig must be serializable to/from a TOML file, and if you want to override the default behavior, you can do it by overriding a method in the Project trait. See the following snippets:

cot/cot/src/project.rs

Lines 335 to 351 in 70995f0

fn auth_backend(&self, context: &AuthBackendContext) -> Arc<dyn AuthBackend> {
#[expect(trivial_casts)] // cast to Arc<dyn AuthBackend>
match &context.config().auth_backend {
AuthBackendConfig::None => Arc::new(NoAuthBackend) as Arc<dyn AuthBackend>,
#[cfg(feature = "db")]
AuthBackendConfig::Database => Arc::new(DatabaseUserBackend::new(
context
.try_database()
.expect(
"Database missing when constructing database auth backend. \
Make sure the database config is set up correctly or disable \
authentication in the config.",
)
.clone(),
)) as Arc<dyn AuthBackend>,
}
}

cot/cot/src/project.rs

Lines 1240 to 1241 in 70995f0

let auth_backend = self.project.auth_backend(&self.context);
let context = self.context.with_auth(auth_backend);

It's not ideal (mainly because it inflates the number of methods in the Project trait), but it's the best solution that we could come up with, allowing the users to use the "common" options easily, but not limiting them in what they can do. However, if you have ideas for a better design, I'm happy to discuss them.

@ElijahAhianyo
Copy link
Contributor Author

@ElijahAhianyo I think we should do this similarly to the Auth backend, i.e. the ProjectConfig should only include a serializable enum of possible choices for the Session store (Memory Store, ORM, Redis, etc., possibly with some inner configs as well), and the Bootstrapper should initialize a proper Session Store backend by calling a method (called session_store?) on the Project trait. The idea is that ProjectConfig must be serializable to/from a TOML file, and if you want to override the default behavior, you can do it by overriding a method in the Project trait. See the following snippets:

cot/cot/src/project.rs

Lines 335 to 351 in 70995f0

fn auth_backend(&self, context: &AuthBackendContext) -> Arc<dyn AuthBackend> {
#[expect(trivial_casts)] // cast to Arc<dyn AuthBackend>
match &context.config().auth_backend {
AuthBackendConfig::None => Arc::new(NoAuthBackend) as Arc<dyn AuthBackend>,
#[cfg(feature = "db")]
AuthBackendConfig::Database => Arc::new(DatabaseUserBackend::new(
context
.try_database()
.expect(
"Database missing when constructing database auth backend. \
Make sure the database config is set up correctly or disable \
authentication in the config.",
)
.clone(),
)) as Arc<dyn AuthBackend>,
}
}

cot/cot/src/project.rs

Lines 1240 to 1241 in 70995f0

let auth_backend = self.project.auth_backend(&self.context);
let context = self.context.with_auth(auth_backend);

It's not ideal (mainly because it inflates the number of methods in the Project trait), but it's the best solution that we could come up with, allowing the users to use the "common" options easily, but not limiting them in what they can do. However, if you have ideas for a better design, I'm happy to discuss them.

Based on your suggestion, I did some research and now have a clearer picture. My plan is to start by sketching out what the TOML file might look like, then work my way down into implementation details.

I looked at how other frameworks handle this and identified two main patterns we canexplore:

1 a. Self-contained store config

You declare the store type and provide any necessary connection details (or file paths, in the case of file storage):

secret_key = "{{ dev_secret_key }}"

[database]
url = "sqlite://db.sqlite3?mode=rwc"

[auth_backend]
type = "database"

[middlewares]
live_reload.enabled = true

[middlewares.session]
secure = false

[middlewares.session.store]
type = "redis"
connection = "redis://"

1 b Per-store subsection

The approach in 1.a could get quite noisy(or maybe not) if different store types have some configs specific to them.

secret_key = "{{ dev_secret_key }}"

[database]
url = "sqlite://db.sqlite3?mode=rwc"

[auth_backend]
type = "database"

[middlewares]
live_reload.enabled = true

[middlewares.session]
secure = false

[middlewares.session.store]
type = "file"

[middlewares.session.store.file]
dir = "/tmp/"

2. Named connections

The first pattern forces you to re-declare connection details even if you’ve already defined them under [databases] or [caches](currently not implemented). It also assumes a single DB/cache backend. We may need to structurally change the API to support this:

secret_key = "{{ dev_secret_key }}"

[databases]
default = "postgres://…"

[caches]
redis_main = "redis://…"

[middlewares.session]
store      = "redis"
connection = "cache.redis_main"  # or, alternatively, use a separate `connection_source` field instead of dotted notation

Option 1 should be easy to implement given our current design. However, with option 2, do we have any plans to support multiple DBs or caches?

@m4tx let me also know if you've got other opinions on what the TOML should look like.

@m4tx
Copy link
Member

m4tx commented Apr 30, 2025

@ElijahAhianyo

do we have any plans to support multiple DBs or caches?

Ah, that's a good question. Definitely yes, but certainly not in the near future.

After some consideration, I think it would make sense to have some sort of hybrid between 1a and 2. I can imagine the following typical use cases for the session backends:

  1. in-memory store, file store - for development or super low traffic websites where performance and reliability don't matter
  2. database (using Cot's ORM) - for development or low/medium traffic websites that need simplicity and reliability, but don't have high enough traffic to require caching
  3. cache (Redis, MongoDB, etc.) - for high traffic websites that need caching for other stuff anyway

If we want to store sessions externally, we probably should already have an API for that. This doesn't apply for in-memory or file stores (because they're mainly development-focused anyway), but we already have a way to define a DB connection in the config (for use with the Cot's ORM) and the framework user shouldn't need to provide the same credentials twice, but the session store should rather use the Cot's ORM directly. The same should be true for Redis/Mongo/whatever cache – we don't have any Cache API at the moment, but I think we should at some point. And when we have the Cache API, we should just be able to use it to implement session stores.

So I think we could try to translate these use cases into Config files:

  1. In-memory/file backend
[middlewares.session.store]
type = "memory"
[middlewares.session.store]
type = "file"
path = "sessions.db"
  1. Database backend
[middlewares.session.store]
type = "database"  # will just use Cot's ORM
  1. Redis

For now, something like so should be good enough:

[middlewares.session.store]
type = "redis"
url = "redis://localhost:6379"

But, when Cot gets a dedicated Cache API with its own connection settings, the above can be just changed to:

[middlewares.session.store]
type = "cache"  # similarly to "database", this just means we'll use Cot's Cache API
name = "cache1"  # optional; only needed if more than one cache is defined

Do you think the above makes sense?

Obviously, implementing a Cache API is out of scope for this PR, but if you'd like to do it as well, you're more than welcome to.

@ElijahAhianyo
Copy link
Contributor Author

@ElijahAhianyo

do we have any plans to support multiple DBs or caches?

Ah, that's a good question. Definitely yes, but certainly not in the near future.

After some consideration, I think it would make sense to have some sort of hybrid between 1a and 2. I can imagine the following typical use cases for the session backends:

  1. in-memory store, file store - for development or super low traffic websites where performance and reliability don't matter
  2. database (using Cot's ORM) - for development or low/medium traffic websites that need simplicity and reliability, but don't have high enough traffic to require caching
  3. cache (Redis, MongoDB, etc.) - for high traffic websites that need caching for other stuff anyway

If we want to store sessions externally, we probably should already have an API for that. This doesn't apply for in-memory or file stores (because they're mainly development-focused anyway), but we already have a way to define a DB connection in the config (for use with the Cot's ORM) and the framework user shouldn't need to provide the same credentials twice, but the session store should rather use the Cot's ORM directly. The same should be true for Redis/Mongo/whatever cache – we don't have any Cache API at the moment, but I think we should at some point. And when we have the Cache API, we should just be able to use it to implement session stores.

So I think we could try to translate these use cases into Config files:

  1. In-memory/file backend
[middlewares.session.store]
type = "memory"
[middlewares.session.store]
type = "file"
path = "sessions.db"
  1. Database backend
[middlewares.session.store]
type = "database"  # will just use Cot's ORM
  1. Redis

For now, something like so should be good enough:

[middlewares.session.store]
type = "redis"
url = "redis://localhost:6379"

But, when Cot gets a dedicated Cache API with its own connection settings, the above can be just changed to:

[middlewares.session.store]
type = "cache"  # similarly to "database", this just means we'll use Cot's Cache API
name = "cache1"  # optional; only needed if more than one cache is defined

Do you think the above makes sense?

Obviously, implementing a Cache API is out of scope for this PR, but if you'd like to do it as well, you're more than welcome to.

@m4tx Yes, that makes sense. I can look into having a Cache API(we can create an issue to track this), in a separate PR—almost async with this one. Would you still want that designed with a singular backend in mind for now, and support multi later?

@m4tx m4tx mentioned this pull request Apr 30, 2025
@m4tx
Copy link
Member

m4tx commented Apr 30, 2025

@ElijahAhianyo actually, I think cases 1 and 3 from my answer can be merged together – there's nothing stopping us from implementing in-memory and file-backed caching. This would leave us with only two session backends that we would need to implement - database and cache.

I've created an issue to track the Cache API: #308.

Would you still want that designed with a singular backend in mind for now, and support multi later?

I think having just one backend is 100% fine for now - we can work on this to add support for multiple ones later, as this doesn't sound like an everyday use case anyways.

@ElijahAhianyo
Copy link
Contributor Author

ElijahAhianyo commented May 1, 2025

@ElijahAhianyo actually, I think cases 1 and 3 from my answer can be merged together – there's nothing stopping us from implementing in-memory and file-backed caching. This would leave us with only two session backends that we would need to implement - database and cache.

I've created an issue to track the Cache API: #308.

Would you still want that designed with a singular backend in mind for now, and support multi later?

I think having just one backend is 100% fine for now - we can work on this to add support for multiple ones later, as this doesn't sound like an everyday use case anyways.

@m4tx I see, just so we're on the same page on what the TOML file should look like in the context of this PR, If we have two(database and cache) store variants. How do we differentiate what cache type(redis, file, memcache, etc) it is from the TOML file?
Would you rather

  1. Ask the user for more help by introducing another config knob to identify what cache type they want:
[middlewares.session.store]
type = "cache"
cache_type = "redis" # very verbose
url = "redis://localhost:6379"
  1. Do some implicit gymnastics using the url/path provided to determine which cache the user wants:
[middlewares.session.store]
type = "cache"
url = "redis://localhost:6379" # will be infered as redis store from the uri
  1. Any other option?

@m4tx
Copy link
Member

m4tx commented May 1, 2025

@ElijahAhianyo I think option number 2 should be good enough, as it doesn't duplicate the data in the config, and it will be consistent with the ORM behavior. When creating the Cache API, we just need to make sure it's possible to register new cache backends (for instance, the cache backend may need to provide all the URL schemas it supports, and our code would just try to match the URL to all registered backends).

By the way, just to be sure, this is fine for now:

[middlewares.session.store]
type = "cache"
url = "redis://localhost:6379"

But eventually we'll want to have something closer to this (when we have the Cache API):

[cache]
URL = "redis://localhost:6379"

[middlewares.session.store]
type = "cache"

@ElijahAhianyo ElijahAhianyo force-pushed the elijah/session-store-dynamic branch from 1c7763d to fe3713b Compare May 13, 2025 13:03
@github-actions github-actions bot added the A-ci Area: CI (Continuous Integration) label May 15, 2025
@github-actions github-actions bot added the C-cli Crate: cot-cli (issues and Pull Requests related to Cot CLI) label May 15, 2025
},
#[cfg(feature = "db")]
Self::Database => {
unimplemented!();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ended up moving the DB implementation into another PR since there are some nuances to it; The DB option requires a migration model and generated migration, which I haven't gotten to the bottom of

@ElijahAhianyo ElijahAhianyo changed the title [WIP]Support Multiple session stores feat:Support Multiple session stores May 26, 2025
@ElijahAhianyo ElijahAhianyo marked this pull request as ready for review May 26, 2025 10:26
@ElijahAhianyo ElijahAhianyo changed the title feat:Support Multiple session stores feat: Support Multiple session stores May 26, 2025
@ElijahAhianyo
Copy link
Contributor Author

ElijahAhianyo commented May 26, 2025

@m4tx @seqre This should somewhat be ready for review, Let me know what your initial thoughts are

Copy link
Member

@m4tx m4tx left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, thanks for this very high-quality contribution! It looks pretty good already, but I have a few minor issues I've pointed out in the comments—please have a look.

let store = MemoryStore::default();
let layer = SessionManagerLayer::new(store);
Self { inner: layer }
pub fn new(store: Arc<dyn SessionStore + Send + Sync>) -> Self {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pub fn new(store: Arc<dyn SessionStore + Send + Sync>) -> Self {
pub fn new<T: SessionStore + Send + Sync + 'static>(store: T) -> Self {

Would this make sense?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this works. Excuse my ignorance, is the idea here to make the API more ergonomic by handling the trait object boxing internally, so callers can pass in concrete types directly?

@@ -15,3 +15,6 @@ cache_timeout = "1year"

[middlewares.session]
secure = false

[middlewares.session.store]
type = "memory"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think there's not much point in specifying this if this is the default value—however, it will be good to set this to "db" when we implement this backend (both for dev and prod configs).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I agree with this. I only had this as a placeholder since we dont really have the db option fully implemented yet. Would you want me to remove this change until the db implementation is ready?

#[derive(Debug, Error)]
/// Errors that can occur when using the Redis session store.
#[non_exhaustive]
pub enum RedisStoreError {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it would be good to make this private and wrap this inside a public struct? This way we won't have to expose deadpool_redis and redis crates in the public API. I'm not quite sure how much value it is for the user to have concrete, public error variants.

What do you think?

Copy link
Contributor Author

@ElijahAhianyo ElijahAhianyo May 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps it would be good to make this private and wrap this inside a public struct?

I'm not sure I follow the public struct wrapping part(would appreciate some further elaboration). Since we typically convert RedisStoreError to session_store::Error, users shouldn't see the concrete types directly unless I'm missing something.
Would it work better to make the enum private and have constructors return Result<Self, session_store::Error>? Though I'm wondering if there are cases where users might benefit from matching on specific error variants when using RedisStore directly (that is if there are cases where users use the stores directly) .

Alternatively, what about keeping the enum public but hiding the external crate details like this:

#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RedisStoreError {
    #[error("Pool connection error: {0}")]
    PoolConnection(String), 
    
    #[error("Pool creation error: {0}")]
    PoolCreation(String),    
    
    #[error("Redis command error: {0}")]
    Command(String),     
    
    #[error("Serialization error: {0}")]
    Serialize(serde_json::Error),  
    
    #[error("Deserialization error: {0}")]
    Deserialize(serde_json::Error),
}

This removes the external crate exposure while staying consistent with our other store error types. Does this approach also seem reasonable?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you feel like it will be valuable for users to have specific error variants, then I'm okay with this. However, I think it will be best to have this instead:

#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RedisStoreError {
    #[error("Pool connection error: {0}")]
    PoolConnection(Box<dyn Error + Send + Sync>), 
    
    #[error("Pool creation error: {0}")]
    PoolCreation(Box<dyn Error + Send + Sync>),    
    
    #[error("Redis command error: {0}")]
    Command(Box<dyn Error + Send + Sync>),     
    
    #[error("Serialization error: {0}")]
    Serialize(Box<dyn Error + Send + Sync>), // not 100% sure if we want to keep serde_json's error types - this sounds like implementation detail
    
    #[error("Deserialization error: {0}")]
    Deserialize(Box<dyn Error + Send + Sync>),
}

I slightly modified your code to have Box<dyn Error> everywhere (I'm okay with having serde_json's error types, though, I think) – this way we won't lose the original error data (it might be useful to display it on Cot's error page), but we won't have API breakage every time redis crates are updated (note that when using concrete error types, even something as harmless-looking as updating deadpool-redis from 0.21 to 0.22 means we need to bump our major version as well, since we effectively expose its API!).

#[cfg(feature = "cache")]
impl From<String> for CacheUrl {
fn from(url: String) -> Self {
Self(url::Url::parse(&url).expect("invalid cache URL"))
Copy link
Contributor Author

@ElijahAhianyo ElijahAhianyo May 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@m4tx I drew inspiration from the DbUrl implementation here. However, if this is Fallible, shouldn't we implement the TryFrom trait instead?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we panic here instead of handling the error on a principle that if a configuration is incorrect, the application should not start anyway.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ci Area: CI (Continuous Integration) C-cli Crate: cot-cli (issues and Pull Requests related to Cot CLI) C-lib Crate: cot (main library crate)
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants