Repository: sanic-org/sanic

Issue:
Can't run normally when using SSLContext
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Describe the bug

```
   app.run(host='0.0.0.0', port=443, ssl=context, dev=True)
  File "/usr/lib/python3.10/site-packages/sanic/mixins/startup.py", line 209, in run
    serve(primary=self)  # type: ignore
  File "/usr/lib/python3.10/site-packages/sanic/mixins/startup.py", line 862, in serve
    manager.run()
  File "/usr/lib/python3.10/site-packages/sanic/worker/manager.py", line 94, in run
    self.start()
  File "/usr/lib/python3.10/site-packages/sanic/worker/manager.py", line 101, in start
    process.start()
  File "/usr/lib/python3.10/site-packages/sanic/worker/process.py", line 53, in start
    self._current_process.start()
  File "/usr/lib/python3.10/multiprocessing/process.py", line 121, in start
    self._popen = self._Popen(self)
  File "/usr/lib/python3.10/multiprocessing/context.py", line 284, in _Popen
    return Popen(process_obj)
  File "/usr/lib/python3.10/multiprocessing/popen_spawn_posix.py", line 32, in __init__
    super().__init__(process_obj)
  File "/usr/lib/python3.10/multiprocessing/popen_fork.py", line 19, in __init__
    self._launch(process_obj)
  File "/usr/lib/python3.10/multiprocessing/popen_spawn_posix.py", line 47, in _launch
    reduction.dump(process_obj, fp)
  File "/usr/lib/python3.10/multiprocessing/reduction.py", line 60, in dump
    ForkingPickler(file, protocol).dump(obj)
TypeError: cannot pickle 'SSLContext' object
```

### Code snippet

```python
from sanic import Sanic
import ssl

context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain("certs/fullchain.pem", "certs/privkey.pem")

app = Sanic('test')

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=443, ssl=context, dev=True)
```

### Expected Behavior

_No response_

### How do you run Sanic?

As a script (`app.run` or `Sanic.serve`)

### Operating System

linux

### Sanic Version

22.12.0

### Additional context

pickle does not support dump ssl.SSLContext causing this problem. 
`multiprocessing.context` use `pickle`

```python
import ssl

context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.load_cert_chain("certs/fullchain.pem", "certs/privkey.pem")
pickle.dumps(context)
```

Discussion:
codecov[bot]: ## [Codecov](https://codecov.io/gh/sanic-org/sanic/pull/2722?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org) Report
Patch coverage: **`100.000`**% and project coverage change: **`+0.108`** :tada:
> Comparison is base [(`5ee36fd`)](https://codecov.io/gh/sanic-org/sanic/commit/5ee36fd933344861cb578105b3ad25b032f22912?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org) 88.607% compared to head [(`5f6304b`)](https://codecov.io/gh/sanic-org/sanic/pull/2722?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org) 88.715%.

> :exclamation: Current head 5f6304b differs from pull request most recent head b147610. Consider uploading reports for the commit b147610 to get more accurate results

<details><summary>Additional details and impacted files</summary>


```diff
@@              Coverage Diff              @@
##              main     #2722       +/-   ##
=============================================
+ Coverage   88.607%   88.715%   +0.108%     
=============================================
  Files           87        87               
  Lines         6820      6841       +21     
  Branches      1171      1176        +5     
=============================================
+ Hits          6043      6069       +26     
+ Misses         535       531        -4     
+ Partials       242       241        -1     
```


| [Impacted Files](https://codecov.io/gh/sanic-org/sanic/pull/2722?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org) | Coverage Δ | |
|---|---|---|
| [sanic/app.py](https://codecov.io/gh/sanic-org/sanic/pull/2722?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org#diff-c2FuaWMvYXBwLnB5) | `90.102% <100.000%> (+0.349%)` | :arrow_up: |
| [sanic/worker/loader.py](https://codecov.io/gh/sanic-org/sanic/pull/2722?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org#diff-c2FuaWMvd29ya2VyL2xvYWRlci5weQ==) | `97.222% <100.000%> (+5.672%)` | :arrow_up: |
| [sanic/worker/serve.py](https://codecov.io/gh/sanic-org/sanic/pull/2722?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org#diff-c2FuaWMvd29ya2VyL3NlcnZlLnB5) | `98.571% <100.000%> (+7.142%)` | :arrow_up: |

... and [1 file with indirect coverage changes](https://codecov.io/gh/sanic-org/sanic/pull/2722/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org)

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org)

</details>

[:umbrella: View full report in Codecov by Sentry](https://codecov.io/gh/sanic-org/sanic/pull/2722?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=sanic-org).


Patch:
diff --git a/sanic/app.py b/sanic/app.py
index 9fb7e027..9efc53ef 100644
--- a/sanic/app.py
+++ b/sanic/app.py
@@ -92,6 +92,7 @@ from sanic.signals import Signal, SignalRouter
 from sanic.touchup import TouchUp, TouchUpMeta
 from sanic.types.shared_ctx import SharedContext
 from sanic.worker.inspector import Inspector
+from sanic.worker.loader import CertLoader
 from sanic.worker.manager import WorkerManager
 
 
@@ -139,6 +140,7 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
         "_test_client",
         "_test_manager",
         "blueprints",
+        "certloader_class",
         "config",
         "configure_logging",
         "ctx",
@@ -181,6 +183,7 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
         loads: Optional[Callable[..., Any]] = None,
         inspector: bool = False,
         inspector_class: Optional[Type[Inspector]] = None,
+        certloader_class: Optional[Type[CertLoader]] = None,
     ) -> None:
         super().__init__(name=name)
         # logging
@@ -215,6 +218,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
         self.asgi = False
         self.auto_reload = False
         self.blueprints: Dict[str, Blueprint] = {}
+        self.certloader_class: Type[CertLoader] = (
+            certloader_class or CertLoader
+        )
         self.configure_logging: bool = configure_logging
         self.ctx: Any = ctx or SimpleNamespace()
         self.error_handler: ErrorHandler = error_handler or ErrorHandler()
diff --git a/sanic/worker/loader.py b/sanic/worker/loader.py
index 344593db..d29f4c68 100644
--- a/sanic/worker/loader.py
+++ b/sanic/worker/loader.py
@@ -5,6 +5,7 @@ import sys
 
 from importlib import import_module
 from pathlib import Path
+from ssl import SSLContext
 from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union, cast
 
 from sanic.http.tls.context import process_to_context
@@ -103,8 +104,16 @@ class CertLoader:
         "trustme": TrustmeCreator,
     }
 
-    def __init__(self, ssl_data: Dict[str, Union[str, os.PathLike]]):
+    def __init__(
+        self,
+        ssl_data: Optional[
+            Union[SSLContext, Dict[str, Union[str, os.PathLike]]]
+        ],
+    ):
         self._ssl_data = ssl_data
+        self._creator_class = None
+        if not ssl_data or not isinstance(ssl_data, dict):
+            return
 
         creator_name = cast(str, ssl_data.get("creator"))
 
diff --git a/sanic/worker/serve.py b/sanic/worker/serve.py
index 39c647b2..583d3eaf 100644
--- a/sanic/worker/serve.py
+++ b/sanic/worker/serve.py
@@ -73,8 +73,8 @@ def worker_serve(
                             info.settings["app"] = a
                         a.state.server_info.append(info)
 
-        if isinstance(ssl, dict):
-            cert_loader = CertLoader(ssl)
+        if isinstance(ssl, dict) or app.certloader_class is not CertLoader:
+            cert_loader = app.certloader_class(ssl or {})
             ssl = cert_loader.load(app)
             for info in app.state.server_info:
                 info.settings["ssl"] = ssl


Test patch:
diff --git a/tests/test_tls.py b/tests/test_tls.py
index d41784f0..6d2cb981 100644
--- a/tests/test_tls.py
+++ b/tests/test_tls.py
@@ -12,7 +12,7 @@ from urllib.parse import urlparse
 
 import pytest
 
-from sanic_testing.testing import HOST, PORT
+from sanic_testing.testing import HOST, PORT, SanicTestClient
 
 import sanic.http.tls.creators
 
@@ -29,6 +29,7 @@ from sanic.http.tls.creators import (
     get_ssl_context,
 )
 from sanic.response import text
+from sanic.worker.loader import CertLoader
 
 
 current_dir = os.path.dirname(os.path.realpath(__file__))
@@ -427,6 +428,29 @@ def test_no_certs_on_list(app):
     assert "No certificates" in str(excinfo.value)
 
 
+def test_custom_cert_loader():
+    class MyCertLoader(CertLoader):
+        def load(self, app: Sanic):
+            self._ssl_data = {
+                "key": localhost_key,
+                "cert": localhost_cert,
+            }
+            return super().load(app)
+
+    app = Sanic("custom", certloader_class=MyCertLoader)
+
+    @app.get("/test")
+    async def handler(request):
+        return text("ssl test")
+
+    client = SanicTestClient(app, port=44556)
+
+    request, response = client.get("https://localhost:44556/test")
+    assert request.scheme == "https"
+    assert response.status_code == 200
+    assert response.text == "ssl test"
+
+
 def test_logger_vhosts(caplog):
     app = Sanic(name="test_logger_vhosts")