Repository: asciinema/asciinema (language: rust)

Issue:
Ugly stacktrace when the target directory for the cast doesn't exist
```
$ asciinema rec foo/bar/qux
asciinema: recording asciicast to foo/bar/qux
asciinema: press <ctrl-d> or type "exit" when you're done
bash-5.2$ Process Process-2:
                            Traceback (most recent call last):
                                                                File "/usr/local/Cellar/python@3.11/3.11.2_1/Frameworks/Python.framework/Versions/3.11/lib/python3.11/multiprocessing/process.py", line 314, in _bootstrap
                                                                                                                                                                                                                              self.run()
                                                                                                                                                                                                                                          File "/usr/local/Cellar/python@3.11/3.11.2_1/Frameworks/Python.framework/Versions/3.11/lib/python3.11/multiprocessing/process.py", line 108, in run
                                                                                                                                   self._target(*self._args, **self._kwargs)
                                                                                                                                                                              File "/usr/local/Cellar/asciinema/2.2.0/libexec/lib/python3.11/site-packages/asciinema/recorder.py", line 141, in run
                                         with self.writer as w:
                                                                 File "/usr/local/Cellar/asciinema/2.2.0/libexec/lib/python3.11/site-packages/asciinema/asciicast/v2.py", line 126, in __enter__
                                                                                                                                                                                                    self._open_file()
                                                                                                                                                                                                                       File "/usr/local/Cellar/asciinema/2.2.0/libexec/lib/python3.11/site-packages/asciinema/asciicast/v2.py", line 156, in _open_file
                                                                                             self.file = open(
                                                                                                                              ^^^^^
                                                                                                                                   FileNotFoundError: [Errno 2] No such file or directory: 'foo/bar/qux'
                                                                                                                                                                                                        Traceback (most recent call last):
  File "/usr/local/bin/asciinema", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/usr/local/Cellar/asciinema/2.2.0/libexec/lib/python3.11/site-packages/asciinema/__main__.py", line 215, in main
    code = command.execute()
           ^^^^^^^^^^^^^^^^^
  File "/usr/local/Cellar/asciinema/2.2.0/libexec/lib/python3.11/site-packages/asciinema/commands/record.py", line 106, in execute
    recorder.record(
  File "/usr/local/Cellar/asciinema/2.2.0/libexec/lib/python3.11/site-packages/asciinema/recorder.py", line 61, in record
    with async_writer(sync_writer, time_offset, record_stdin) as _writer:
  File "/usr/local/Cellar/asciinema/2.2.0/libexec/lib/python3.11/site-packages/asciinema/async_worker.py", line 35, in __exit__
    raise RuntimeError(
RuntimeError: worker process exited with code 1
```
Error when attempting to run using Ubuntu 22.04 with Pip 22.0.2 / Python 3.10
I installed it and tried to run, and it came back with permission error

```
stew@stew /> sudo apt install ascicinema
[sudo] password for stew: 
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
E: Unable to locate package ascicinema
stew@stew / [100]> pip3 install asciinema
Defaulting to user installation because normal site-packages is not writeable
Collecting asciinema
  Downloading asciinema-2.2.0-py3-none-any.whl (92 kB)
     ━━━━━━━━━━━━━━━━━━━━━━━━ 92.5/92.5 KB 558.2 kB/s eta 0:00:00
Installing collected packages: asciinema
Successfully installed asciinema-2.2.0
stew@stew /> asciinema rec first.cast
asciinema: recording asciicast to first.cast
asciinema: press <ctrl-d> or type "exit" when you're done
Process Process-2:
                  Traceback (most recent call last):
                                                      File "/usr/lib/python3.10/multiprocessing/process.py", line 314, in _bootstrap
      self.run()
                  File "/usr/lib/python3.10/multiprocessing/process.py", line 108, in run
                            self._target(*self._args, **self._kwargs)
      File "/home/stew/.local/lib/python3.10/site-packages/asciinema/recorder.py", line 141, in run
                                      with self.writer as w:
                                                              File "/home/stew/.local/lib/python3.10/site-packages/asciinema/asciicast/v2.py", line 126, in __enter__
                                       self._open_file()
                                                          File "/home/stew/.local/lib/python3.10/site-packages/asciinema/asciicast/v2.py", line 156, in _open_file
                                    self.file = open(
                                                     PermissionError: [Errno 13] Permission denied: 'first.cast'
                                               Traceback (most recent call last):
  File "/home/stew/.local/bin/asciinema", line 8, in <module>
    sys.exit(main())
  File "/home/stew/.local/lib/python3.10/site-packages/asciinema/__main__.py", line 215, in main
    code = command.execute()
  File "/home/stew/.local/lib/python3.10/site-packages/asciinema/commands/record.py", line 106, in execute
    recorder.record(
  File "/home/stew/.local/lib/python3.10/site-packages/asciinema/recorder.py", line 61, in record
    with async_writer(sync_writer, time_offset, record_stdin) as _writer:
  File "/home/stew/.local/lib/python3.10/site-packages/asciinema/async_worker.py", line 35, in __exit__
    raise RuntimeError(
RuntimeError: worker process exited with code 1
```

asciinema cat fails with OSError: [Errno 6] No such device or address: '/dev/tty' when running from GitHub Action
**Describe the bug**

In https://github.com/enola-dev/enola/pull/142, I would like to run `asciinema` from a GitHub Action.

**To Reproduce**

Create a GitHub `.github/workflows/asciinema.yaml` with a `run:` of something which launches `asciinema` triggers:

```
asciinema: recording asciicast to /home/runner/work/enola/enola/docs/use/library/script.cast
asciinema: exit opened program when you're done
asciinema: recording finished
asciinema: asciicast saved to /home/runner/work/enola/enola/docs/use/library/script.cast

+ svg-term --window --width 80 --height 25 --in /home/runner/work/enola/enola/docs/use/library/script.cast --out /home/runner/work/enola/enola/docs/use/library/script.svg
+ asciinema cat /home/runner/work/enola/enola/docs/use/library/script.cast
+ col -b
+ perl -pe 's/\e([^\[\]]|\[.*?[a-zA-Z]|\].*?\a)//g'
Traceback (most recent call last):
  File "/home/runner/work/enola/enola/.venv/bin/asciinema", line 8, in <module>
    sys.exit(main())
  File "/home/runner/work/enola/enola/.venv/lib/python3.10/site-packages/asciinema/__main__.py", line 215, in main
    code = command.execute()
  File "/home/runner/work/enola/enola/.venv/lib/python3.10/site-packages/asciinema/commands/cat.py", line 17, in execute
    with open("/dev/tty", "rt", encoding="utf-8") as stdin:
OSError: [Errno 6] No such device or address: '/dev/tty'
Error: Process completed with exit code 123.
```

**Versions:**
 - OS: Ubuntu VM from GitHub Action
 - asciinema cli: `2.2.0`

**Additional context**

I gathered this is related to https://github.com/actions/runner/issues/241, and understand that this isn't so much a "bug" of `asciinema` but a limitation of the Linux environment of a GitHub Action not having a `/dev/tty`.

But perhaps someone can provide some input or suggestions how to work around this? I've found https://github.com/tarides/meio/pull/48/files where @TheLortex patches a `get_terminal_size()` - but that doesn't seem to be where it fails above...


Discussion / hints:

Fix patch:
diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md
new file mode 100644
index 000000000..569244bce
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug-report.md
@@ -0,0 +1,38 @@
+---
+name: Bug report
+about: Create a report to help improve asciinema
+title: ''
+labels: ''
+assignees: ''
+
+---
+
+To make life of the project maintainers easier please submit bug reports only.
+
+This is bug tracker for asciinema cli (aka recorder). If your issue seems to be with another component (js player, server) then open an issue in related repository. If you're experiencing issue with asciinema server running at asciinema.org then contact admin@asciinema.org.
+
+Ideas, feature requests, help requests, questions and general discussions should be discussed in project's Discussions instead: https://github.com/orgs/asciinema/discussions
+
+If you think you've found a bug or regression, go ahead and delete this message and fill in the details below.
+
+-----
+
+**Describe the bug**
+A clear and concise description of what the bug is.
+
+**To Reproduce**
+Steps to reproduce the behavior:
+1. ...
+2. ...
+3. ...
+4. See error
+
+**Expected behavior**
+A clear and concise description of what you expected to happen.
+
+**Versions:**
+ - OS: [e.g. macOS 12.6, Ubuntu 23.04]
+ - asciinema cli: [e.g. 2.2.0]
+
+**Additional context**
+Add any other context about the problem here.
diff --git a/.github/workflows/asciinema.yml b/.github/workflows/asciinema.yml
index 7a3200ed6..c7805f857 100644
--- a/.github/workflows/asciinema.yml
+++ b/.github/workflows/asciinema.yml
@@ -12,7 +12,7 @@ jobs:
       - name: checkout asciinema
         uses: actions/checkout@v3
       - name: setup Python
-        uses: actions/setup-python@v3
+        uses: actions/setup-python@v4
         with:
           python-version: "3.10"
       - name: install dependencies
@@ -33,18 +33,18 @@ jobs:
     strategy:
       matrix:
         python:
-          - "3.6"
           - "3.7"
           - "3.8"
           - "3.9"
           - "3.10"
+          - "3.11"
     env:
       TERM: dumb
     steps:
       - name: checkout Asciinema
         uses: actions/checkout@v3
       - name: setup Python
-        uses: actions/setup-python@v3
+        uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python }}
       - name: install dependencies
@@ -74,7 +74,7 @@ jobs:
           username: "${{ github.actor }}"
           password: "${{ secrets.GITHUB_TOKEN }}"
       - name: "Build ${{ matrix.distros }} image"
-        uses: docker/build-push-action@v3
+        uses: docker/build-push-action@v4
         with:
           file: "tests/distros/Dockerfile.${{ matrix.distros }}"
           tags: |
diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml
index 5c1a29d00..007aef24e 100644
--- a/.github/workflows/pre-commit.yml
+++ b/.github/workflows/pre-commit.yml
@@ -8,7 +8,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v3
-      - uses: actions/setup-python@v3
+      - uses: actions/setup-python@v4
         with:
           python-version: "3.10"
-      - uses: pre-commit/action@v2.0.3
+      - uses: pre-commit/action@v3.0.0
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 8d7215e62..5b93f6e00 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,7 +1,7 @@
 ---
 repos:
   - repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.1.0
+    rev: v4.4.0
     hooks:
       - id: check-ast
       - id: check-json
@@ -11,19 +11,19 @@ repos:
       - id: requirements-txt-fixer
       - id: trailing-whitespace
   - repo: https://github.com/PyCQA/isort
-    rev: 5.10.1
+    rev: 5.12.0
     hooks:
       - id: isort
   - repo: https://github.com/psf/black
-    rev: 22.3.0
+    rev: 23.1.0
     hooks:
       - id: black
   - repo: https://github.com/adrienverge/yamllint
-    rev: v1.26.3
+    rev: v1.29.0
     hooks:
       - id: yamllint
   - repo: https://github.com/myint/autoflake
-    rev: v1.4
+    rev: v2.0.2
     hooks:
       - id: autoflake
         args:
@@ -34,6 +34,6 @@ repos:
           - --remove-duplicate-keys
           - --remove-unused-variables
   - repo: https://github.com/pre-commit/mirrors-mypy
-    rev: "v0.931"
+    rev: "v1.1.1"
     hooks:
       - id: mypy
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7013afd1..ffa8bfb73 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,25 @@
 # asciinema changelog
 
+## 2.3.0 (2023-07-05)
+
+* Added official support for Python 3.11
+* Dropped official support for Python 3.6
+* Implemented markers in `rec` and `play -m` commands
+* Added `--loop` option for looped playback in `play` command
+* Added `--stream` and `--out-fmt` option for customizing output of `play` command
+* Improved terminal charset detection (thanks @djds)
+* Extended `cat` command to support multiple files (thanks @Low-power)
+* Improved upload error messages
+* Fixed direct playback from URL
+* Made raw output start with terminal size sequence (`\e[8;H;Wt`)
+* Prevented recording to stdout when it's a TTY
+* Added target file permission checks to avoid ugly errors
+* Removed named pipe re-opening, which was causing hangs in certain scenarios
+* Improved PTY/TTY data reading - it goes in bigger chunks now (256 kb)
+* Fixed deadlock in PTY writes (thanks @Low-power)
+* Improved input forwarding from stdin
+* Ignored OSC responses in recorded stdin stream
+
 ## 2.2.0 (2022-05-07)
 
 * Added official support for Python 3.8, 3.9, 3.10
diff --git a/Dockerfile b/Dockerfile
index 43eb960be..0fc2b8c60 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,6 +1,6 @@
 # syntax=docker/dockerfile:1.3
 
-FROM docker.io/library/ubuntu:20.04
+FROM docker.io/library/ubuntu:22.04
 
 ENV DEBIAN_FRONTEND="noninteractive"
 
diff --git a/README.md b/README.md
index 942c53f0b..22b687e4c 100644
--- a/README.md
+++ b/README.md
@@ -133,18 +133,18 @@ python3 -m asciinema --version
 ### Docker image
 
 asciinema Docker image is based on [Ubuntu
-20.04](https://releases.ubuntu.com/20.04/) and has the latest version of
+22.04](https://releases.ubuntu.com/22.04/) and has the latest version of
 asciinema recorder pre-installed.
 
 ```sh
-docker pull docker.io/asciinema/asciinema
+docker pull ghcr.io/asciinema/asciinema
 ```
 
 When running it don't forget to allocate a pseudo-TTY (`-t`), keep STDIN open
 (`-i`) and mount config directory volume (`-v`):
 
 ```sh
-docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" docker.io/asciinema/asciinema rec
+docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" ghcr.io/asciinema/asciinema rec
 ```
 
 Container's entrypoint is set to `/usr/local/bin/asciinema` so you can run the
@@ -153,11 +153,13 @@ Usage section for commands and options).
 
 There's not much software installed in this image though. In most cases you may
 want to install extra programs before recording. One option is to derive new
-image from this one (start your custom Dockerfile with `FROM asciinema/asciinema`). Another option is to start the container with `/bin/bash`
-as the entrypoint, install extra packages and manually start `asciinema rec`:
+image from this one (start your custom Dockerfile with `FROM
+ghcr.io/asciinema/asciinema`). Another option is to start the container with
+`/bin/bash` as the entrypoint, install extra packages and manually start
+`asciinema rec`:
 
 ```console
-docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" --entrypoint=/bin/bash docker.io/asciinema/asciinema rec
+docker run --rm -it -v "${HOME}/.config/asciinema:/root/.config/asciinema" --entrypoint=/bin/bash ghcr.io/asciinema/asciinema rec
 root@6689517d99a1:~# apt-get install foobar
 root@6689517d99a1:~# asciinema rec
 ```
@@ -173,7 +175,7 @@ docker run --rm -it \
     --volume="${HOME}/.config/asciinema:/run/user/$(id -u)/.config/asciinema:rw" \
     --volume="${PWD}:/data:rw" \
     --workdir='/data' \
-    docker.io/asciinema/asciinema rec
+    ghcr.io/asciinema/asciinema rec
 ```
 
 ## Usage
@@ -236,7 +238,7 @@ Available options:
 Stdin recording allows for capturing of all characters typed in by the user in
 the currently recorded shell. This may be used by a player (e.g.
 [asciinema-player](https://github.com/asciinema/asciinema-player)) to display
-pressed keys. Because it's basically a key-logging (scoped to a single shell
+pressed keys. Because it's basically key-logging (scoped to a single shell
 instance), it's disabled by default, and has to be explicitly enabled via
 `--stdin` option.
 
@@ -293,6 +295,21 @@ Available options:
 
 - `-i, --idle-time-limit=<sec>` - Limit replayed terminal inactivity to max `<sec>` seconds
 - `-s, --speed=<factor>` - Playback speed (can be fractional)
+- `-l, --loop` - Play in a loop
+- `-m, --pause-on-markers` - Automatically pause on [markers](#markers)
+- `--stream=<stream>` - Select stream to play (see below)
+- `--out-fmt=<format>` - Select output format (see below)
+
+By default the output stream (`o`) is played. This is what you want in most
+cases.  If you recorded the input stream (`i`) with `asciinema rec --stdin` then
+you can replay it with `asciinema play --stream=i <filename>`.
+
+By default the selected stream is written to stdout in original, raw data form.
+This is also what you want in majority of cases. However you can change the
+output format to asciicast (newline delimited JSON) with `asciinema play
+--out-fmt=asciicast <filename>`. This allows delegating actual rendering to
+another place (e.g. outside of your terminal) by piping output of `asciinema
+play` to a tool of your choice.
 
 > For the best playback experience it is recommended to run `asciinema play` in
 > a terminal of dimensions not smaller than the one used for recording, as
@@ -345,6 +362,29 @@ happen in any order.
 > asciinema versions prior to 2.0 confusingly referred to install ID as "API
 > token".
 
+## Markers
+
+Markers allow marking specific time locations in a recording, which can be used
+for navigation, as well as for automatic pausing of the playback.
+
+Markers can be added to a recording in several ways:
+
+- while you are recording, by pressing a configured hotkey, see [add_marker_key
+  config option](#configuration-file)
+- for existing recording, by inserting marker events (`"m"`) in the asciicast
+  file, see [marker event](doc/asciicast-v2.md#m---marker)
+
+When replaying a recording with `asciinema play` you can enable
+auto-pause-on-marker behaviour with `-m`/`--pause-on-markers` option (it's off
+by default). When a marker is encountered, the playback automatically pauses and
+can be resumed by pressing space bar key. The playback continues until next
+marker is encountered. You can also fast-forward to the next marker by pressing
+`]` key (when paused).
+
+Markers can be useful in e.g. live demos: you can create a recording with
+markers, then play it back during presentation, and have it stop wherever you
+want to explain terminal contents in more detail.
+
 ## Hosting the recordings on the web
 
 As mentioned in the `Usage > rec` section above, if the `filename` argument to
@@ -403,6 +443,9 @@ quiet = true
 ; default: C-\ (control + backslash)
 pause_key = C-p
 
+; Define hotkey for adding a marker, default: none
+add_marker_key = C-x
+
 ; Define hotkey prefix key - when defined other recording hotkeys must
 ; be preceeded by it, default: no prefix
 prefix_key = C-a
@@ -420,8 +463,12 @@ idle_time_limit = 1
 pause_key = p
 
 ; Define hotkey for stepping through playback, a frame at a time,
-; default: .
-step_key = ]
+; default: . (dot)
+step_key = s
+
+; Define hotkey for jumping to the next marker,
+; default: ]
+next_marker_key = m
 
 [notifications]
 ; Desktop notifications are displayed on certain occasions, e.g. when
@@ -455,6 +502,10 @@ If `$XDG_CONFIG_HOME` is set on Linux then asciinema uses
 > asciinema versions prior to 1.1 used `$HOME/.asciinema`. If you have it
 > there you should `mv $HOME/.asciinema $HOME/.config/asciinema`.
 
+## Consulting
+
+I offer consulting services for asciinema project. See https://asciinema.org/consulting for more information.
+
 ## Contributing
 
 If you want to contribute to this project check out
@@ -467,7 +518,7 @@ source [contributors](https://github.com/asciinema/asciinema/contributors).
 
 ## License
 
-Copyright &copy; 2011–2021 Marcin Kulik.
+© 2011 Marcin Kulik.
 
 All code is licensed under the GPL, v3 or later. See [LICENSE](./LICENSE) file
 for details.
diff --git a/asciinema/__main__.py b/asciinema/__main__.py
index 7c11928d6..493f89f64 100644
--- a/asciinema/__main__.py
+++ b/asciinema/__main__.py
@@ -12,6 +12,21 @@
 from .commands.upload import UploadCommand
 
 
+def valid_encoding() -> bool:
+    def _locales() -> Optional[str]:
+        try:
+            return locale.nl_langinfo(locale.CODESET)
+        except AttributeError:
+            return locale.getlocale()[-1]
+
+    loc = _locales()
+
+    if loc is None:
+        return False
+    else:
+        return loc.upper() in ("US-ASCII", "UTF-8", "UTF8")
+
+
 def positive_int(value: str) -> int:
     _value = int(value)
     if _value <= 0:
@@ -35,11 +50,7 @@ def maybe_str(v: Any) -> Optional[str]:
 
 
 def main() -> Any:
-    if locale.nl_langinfo(locale.CODESET).upper() not in [
-        "US-ASCII",
-        "UTF-8",
-        "UTF8",
-    ]:
+    if not valid_encoding():
         sys.stderr.write(
             "asciinema needs an ASCII or UTF-8 character encoding to run. "
             "Check the output of `locale` command.\n"
@@ -174,10 +185,36 @@ def main() -> Any:
     parser_play.add_argument(
         "-s",
         "--speed",
-        help="playback speedup (can be fractional)",
+        help="set playback speed (can be fractional)",
         type=positive_float,
         default=cfg.play_speed,
     )
+    parser_play.add_argument(
+        "-l",
+        "--loop",
+        help="loop loop loop loop",
+        action="store_true",
+        default=False,
+    )
+    parser_play.add_argument(
+        "-m",
+        "--pause-on-markers",
+        help="automatically pause on markers",
+        action="store_true",
+        default=False,
+    )
+    parser_play.add_argument(
+        "--out-fmt",
+        help="select output format",
+        choices=["raw", "asciicast"],
+        default="raw",
+    )
+    parser_play.add_argument(
+        "--stream",
+        help="select stream to play",
+        choices=["o", "i"],
+        default=None,
+    )
     parser_play.add_argument(
         "filename", help='local path, http/ipfs URL or "-" (read from stdin)'
     )
@@ -185,10 +222,12 @@ def main() -> Any:
 
     # create the parser for the `cat` command
     parser_cat = subparsers.add_parser(
-        "cat", help="Print full output of terminal session"
+        "cat", help="Print full output of terminal sessions"
     )
     parser_cat.add_argument(
-        "filename", help='local path, http/ipfs URL or "-" (read from stdin)'
+        "filename",
+        nargs="+",
+        help='local path, http/ipfs URL or "-" (read from stdin)',
     )
     parser_cat.set_defaults(cmd=CatCommand)
 
diff --git a/asciinema/api.py b/asciinema/api.py
index 61ac437ec..a3914fa99 100644
--- a/asciinema/api.py
+++ b/asciinema/api.py
@@ -10,7 +10,9 @@
 
 
 class APIError(Exception):
-    pass
+    def __init__(self, e: str, retryable: bool):
+        super().__init__(e)
+        self.retryable = retryable
 
 
 class Api:
@@ -48,7 +50,7 @@ def upload_asciicast(self, path_: str) -> Tuple[Any, Any]:
                     password=self.install_id,
                 )
             except HTTPConnectionError as e:
-                raise APIError(str(e)) from e
+                raise APIError(str(e), True) from e
 
         if status not in (200, 201):
             self._handle_error(status, body)
@@ -82,9 +84,9 @@ def _handle_error(status: int, body: bytes) -> None:
                 "This asciinema version may no longer be supported. "
                 "Please upgrade to the latest version."
             ),
-            413: "Sorry, your asciicast is too big.",
+            413: "Sorry, the size of your recording exceeds the server-configured limit.",
             422: f"Invalid asciicast: {body.decode('utf-8', 'replace')}",
-            503: "The server is down for maintenance. Try again in a minute.",
+            503: "The server is down for maintenance.",
         }
 
         error = errors.get(status)
@@ -98,4 +100,4 @@ def _handle_error(status: int, body: bytes) -> None:
             else:
                 error = f"HTTP status: {status}"
 
-        raise APIError(error)
+        raise APIError(error, status >= 500)
diff --git a/asciinema/asciicast/__init__.py b/asciinema/asciicast/__init__.py
index 1cd4a9262..b693fde12 100644
--- a/asciinema/asciicast/__init__.py
+++ b/asciinema/asciicast/__init__.py
@@ -5,6 +5,7 @@
 import urllib.error
 from codecs import StreamReader
 from html.parser import HTMLParser
+from io import BytesIO
 from typing import Any, List, TextIO, Union
 from urllib.parse import urlparse, urlunparse
 from urllib.request import Request, urlopen
@@ -58,46 +59,50 @@ def open_url(url: str) -> Union[StreamReader, TextIO]:
     if url.startswith("http:") or url.startswith("https:"):
         req = Request(url)
         req.add_header("Accept-Encoding", "gzip")
+        body = None
+        content_type = None
+        utf8_reader = codecs.getreader("utf-8")
+
         with urlopen(req) as response:
             body = response
+            content_type = response.headers["Content-Type"]
             url = response.geturl()  # final URL after redirects
 
             if response.headers["Content-Encoding"] == "gzip":
                 body = gzip.open(body)
 
-            utf8_reader = codecs.getreader("utf-8")
-            content_type = response.headers["Content-Type"]
+            body = BytesIO(body.read())
 
-            if content_type and content_type.startswith("text/html"):
-                html = utf8_reader(body, errors="replace").read()
-                parser = Parser()
-                parser.feed(html)
-                new_url = parser.url
-
-                if not new_url:
-                    raise LoadError(
-                        '<link rel="alternate" '
-                        'type="application/x-asciicast" '
-                        'href="..."> '
-                        "not found in fetched HTML document"
-                    )
+        if content_type and content_type.startswith("text/html"):
+            html = utf8_reader(body, errors="replace").read()
+            parser = Parser()
+            parser.feed(html)
+            new_url = parser.url
 
-                if "://" not in new_url:
-                    base_url = urlparse(url)
+            if not new_url:
+                raise LoadError(
+                    '<link rel="alternate" '
+                    'type="application/x-asciicast" '
+                    'href="..."> '
+                    "not found in fetched HTML document"
+                )
 
-                    if new_url.startswith("/"):
-                        new_url = urlunparse(
-                            (base_url[0], base_url[1], new_url, "", "", "")
-                        )
-                    else:
-                        path = f"{os.path.dirname(base_url[2])}/{new_url}"
-                        new_url = urlunparse(
-                            (base_url[0], base_url[1], path, "", "", "")
-                        )
+            if "://" not in new_url:
+                base_url = urlparse(url)
+
+                if new_url.startswith("/"):
+                    new_url = urlunparse(
+                        (base_url[0], base_url[1], new_url, "", "", "")
+                    )
+                else:
+                    path = f"{os.path.dirname(base_url[2])}/{new_url}"
+                    new_url = urlunparse(
+                        (base_url[0], base_url[1], path, "", "", "")
+                    )
 
-                return open_url(new_url)
+            return open_url(new_url)
 
-            return utf8_reader(body, errors="strict")
+        return utf8_reader(body, errors="strict")
 
     return open(url, mode="rt", encoding="utf-8")
 
diff --git a/asciinema/asciicast/events.py b/asciinema/asciicast/events.py
index 87f81c342..7463f94af 100644
--- a/asciinema/asciicast/events.py
+++ b/asciinema/asciicast/events.py
@@ -1,8 +1,8 @@
-from typing import Any, Generator, List, Optional
+from typing import Any, Generator, Iterable, List, Optional
 
 
 def to_relative_time(
-    events: Generator[List[Any], None, None]
+    events: Iterable[Any],
 ) -> Generator[List[Any], None, None]:
     prev_time = 0
 
@@ -14,7 +14,7 @@ def to_relative_time(
 
 
 def to_absolute_time(
-    events: Generator[List[Any], None, None]
+    events: Iterable[Any],
 ) -> Generator[List[Any], None, None]:
     time = 0
 
@@ -25,8 +25,8 @@ def to_absolute_time(
 
 
 def cap_relative_time(
-    events: Generator[List[Any], None, None], time_limit: Optional[float]
-) -> Generator[List[Any], None, None]:
+    events: Iterable[Any], time_limit: Optional[float]
+) -> Iterable[Any]:
     if time_limit:
         return (
             [min(delay, time_limit), type_, data]
@@ -36,6 +36,6 @@ def cap_relative_time(
 
 
 def adjust_speed(
-    events: Generator[List[Any], None, None], speed: Any
+    events: Iterable[Any], speed: Any
 ) -> Generator[List[Any], None, None]:
     return ([delay / speed, type_, data] for delay, type_, data in events)
diff --git a/asciinema/asciicast/raw.py b/asciinema/asciicast/raw.py
index b52441f97..874385a1e 100644
--- a/asciinema/asciicast/raw.py
+++ b/asciinema/asciicast/raw.py
@@ -23,8 +23,23 @@ def __init__(  # pylint: disable=too-many-arguments
             append = False
 
         self.buffering = buffering
-        self.mode: str = "ab" if append else "wb"
-        self.metadata = metadata
+
+        if append:
+            self.mode = "ab"
+            self.header = None
+        else:
+            self.mode = "wb"
+            width = metadata["width"]
+            height = metadata["height"]
+            self.header = f"\x1b[8;{height};{width}t".encode("utf-8")
+
+    def __enter__(self) -> Any:
+        super().__enter__()
+
+        if self.header:
+            self._write(self.header)
+
+        return self
 
     def write_stdout(self, _ts: float, data: Any) -> None:
         self._write(data)
@@ -33,6 +48,9 @@ def write_stdout(self, _ts: float, data: Any) -> None:
     def write_stdin(self, ts: float, data: Any) -> None:
         pass
 
+    def write_marker(self, ts: float) -> None:
+        pass
+
     # pylint: disable=consider-using-with
     def _open_file(self) -> None:
         if self.path == "-":
diff --git a/asciinema/asciicast/v1.py b/asciinema/asciicast/v1.py
index a1f754b68..d188c1252 100644
--- a/asciinema/asciicast/v1.py
+++ b/asciinema/asciicast/v1.py
@@ -1,7 +1,16 @@
 import json
 from codecs import StreamReader
 from json.decoder import JSONDecodeError
-from typing import Any, Dict, Generator, List, Optional, TextIO, Union
+from typing import (
+    Any,
+    Dict,
+    Generator,
+    Iterable,
+    List,
+    Optional,
+    TextIO,
+    Union,
+)
 
 from .events import to_absolute_time
 
@@ -26,16 +35,16 @@ def v2_header(self) -> Dict[str, Any]:
         }
         return header
 
+    def events(self, type_: Optional[str] = None) -> Iterable[List[Any]]:
+        if type_ in [None, "o"]:
+            return to_absolute_time(self.__stdout_events())
+        else:
+            return []
+
     def __stdout_events(self) -> Generator[List[Any], None, None]:
         for time, data in self.__attrs["stdout"]:
             yield [time, "o", data]
 
-    def events(self) -> Any:
-        return self.stdout_events()
-
-    def stdout_events(self) -> Generator[List[Any], None, None]:
-        return to_absolute_time(self.__stdout_events())
-
 
 class open_from_file:
     FORMAT_ERROR: str = "only asciicast v1 format can be opened"
diff --git a/asciinema/asciicast/v2.py b/asciinema/asciicast/v2.py
index c9dcb8f09..0ec4765df 100644
--- a/asciinema/asciicast/v2.py
+++ b/asciinema/asciicast/v2.py
@@ -31,14 +31,17 @@ def __init__(
         self.v2_header = header
         self.idle_time_limit = header.get("idle_time_limit")
 
-    def events(self) -> Generator[Any, None, None]:
-        for line in self.__file:
-            yield json.loads(line)
-
-    def stdout_events(self) -> Generator[List[Any], None, None]:
-        for time, type_, data in self.events():
-            if type_ == "o":
-                yield [time, type_, data]
+    def events(
+        self, type_: Optional[str] = None
+    ) -> Generator[List[Any], None, None]:
+        if type_ is None:
+            for line in self.__file:
+                yield json.loads(line)
+        else:
+            for line in self.__file:
+                event = json.loads(line)
+                if event[1] == type_:
+                    yield event
 
 
 def build_from_header_and_file(
@@ -76,7 +79,7 @@ def get_duration(path_: str) -> Any:
         first_line = f.readline()
         with open_from_file(first_line, f) as a:
             last_frame = None
-            for last_frame in a.stdout_events():
+            for last_frame in a.events("o"):
                 pass
             return last_frame[0]
 
@@ -142,6 +145,9 @@ def write_stdin(self, ts: float, data: Union[str, bytes]) -> None:
         data = self.stdin_decoder.decode(data)
         self.__write_event(ts, "i", data)
 
+    def write_marker(self, ts: float) -> None:
+        self.__write_event(ts, "m", "")
+
     # pylint: disable=consider-using-with
     def _open_file(self) -> None:
         if self.path == "-":
diff --git a/asciinema/async_worker.py b/asciinema/async_worker.py
index fbfcbb82b..b1dc3951e 100644
--- a/asciinema/async_worker.py
+++ b/asciinema/async_worker.py
@@ -20,10 +20,16 @@ def __init__(self) -> None:
         self.process: Optional[Process] = None
 
     def __enter__(self) -> Any:
-        self.process = Process(target=self.run)
+        self.process = Process(target=self._run)
         self.process.start()
         return self
 
+    def _run(self) -> None:
+        try:
+            self.run()
+        except KeyboardInterrupt:
+            pass
+
     def __exit__(
         self, exc_type: str, exc_value: str, exc_traceback: str
     ) -> None:
diff --git a/asciinema/commands/cat.py b/asciinema/commands/cat.py
index ce5ac9393..6c9327c54 100644
--- a/asciinema/commands/cat.py
+++ b/asciinema/commands/cat.py
@@ -10,16 +10,23 @@
 class CatCommand(Command):
     def __init__(self, args: Any, config: Config, env: Dict[str, str]):
         Command.__init__(self, args, config, env)
-        self.filename = args.filename
+        self.filenames = args.filename
 
     def execute(self) -> int:
         try:
             with open("/dev/tty", "rt", encoding="utf-8") as stdin:
                 with raw(stdin.fileno()):
-                    with asciicast.open_from_url(self.filename) as a:
-                        for _, _type, text in a.stdout_events():
-                            sys.stdout.write(text)
-                            sys.stdout.flush()
+                    return self.cat()
+        except OSError:
+            return self.cat()
+
+    def cat(self) -> int:
+        try:
+            for filename in self.filenames:
+                with asciicast.open_from_url(filename) as a:
+                    for _, _type, text in a.events("o"):
+                        sys.stdout.write(text)
+                        sys.stdout.flush()
 
         except asciicast.LoadError as e:
             self.print_error(f"printing failed: {str(e)}")
diff --git a/asciinema/commands/command.py b/asciinema/commands/command.py
index 5de55ddff..e4f786f4a 100644
--- a/asciinema/commands/command.py
+++ b/asciinema/commands/command.py
@@ -14,7 +14,7 @@ def __init__(self, _args: Any, config: Config, env: Dict[str, str]):
     def print(
         self,
         text: str,
-        end: str = "\n",
+        end: str = "\r\n",
         color: Optional[int] = None,
         force: bool = False,
     ) -> None:
diff --git a/asciinema/commands/play.py b/asciinema/commands/play.py
index 4f135e7a0..a258d5368 100644
--- a/asciinema/commands/play.py
+++ b/asciinema/commands/play.py
@@ -18,17 +18,37 @@ def __init__(
         self.filename = args.filename
         self.idle_time_limit = args.idle_time_limit
         self.speed = args.speed
+        self.loop = args.loop
+        self.out_fmt = args.out_fmt
+        self.stream = args.stream
+        self.pause_on_markers = args.pause_on_markers
         self.player = player if player is not None else Player()
         self.key_bindings = {
             "pause": config.play_pause_key,
             "step": config.play_step_key,
+            "next_marker": config.play_next_marker_key,
         }
 
     def execute(self) -> int:
+        code = self.play()
+
+        if self.loop:
+            while code == 0:
+                code = self.play()
+
+        return code
+
+    def play(self) -> int:
         try:
             with asciicast.open_from_url(self.filename) as a:
                 self.player.play(
-                    a, self.idle_time_limit, self.speed, self.key_bindings
+                    a,
+                    idle_time_limit=self.idle_time_limit,
+                    speed=self.speed,
+                    key_bindings=self.key_bindings,
+                    out_fmt=self.out_fmt,
+                    stream=self.stream,
+                    pause_on_markers=self.pause_on_markers,
                 )
 
         except asciicast.LoadError as e:
diff --git a/asciinema/commands/record.py b/asciinema/commands/record.py
index d60cdf547..e60002d23 100644
--- a/asciinema/commands/record.py
+++ b/asciinema/commands/record.py
@@ -34,6 +34,7 @@ def __init__(self, args: Any, config: Config, env: Dict[str, str]) -> None:
         self.key_bindings = {
             "prefix": config.record_prefix_key,
             "pause": config.record_pause_key,
+            "add_marker": config.record_add_marker_key,
         }
 
     # pylint: disable=too-many-branches
@@ -53,6 +54,12 @@ def execute(self) -> int:
             upload = True
 
         if self.filename == "-":
+            if sys.stdout.isatty():
+                self.print_error(
+                    f"when recording to stdout it must not be TTY - forgot to pipe?"
+                )
+                return 1
+
             append = False
 
         elif os.path.exists(self.filename):
@@ -76,16 +83,30 @@ def execute(self) -> int:
                 )
                 return 1
 
-        elif append:
-            self.print_warning(
-                f"{self.filename} does not exist, not appending"
-            )
-            append = False
+        else:
+            dir_path = os.path.dirname(os.path.abspath(self.filename))
+
+            if not os.path.exists(dir_path):
+                self.print_error(f"directory {dir_path} doesn't exist")
+                return 1
+
+            if not os.access(dir_path, os.W_OK):
+                self.print_error(f"directory {dir_path} is not writable")
+                return 1
+
+            if append:
+                self.print_warning(
+                    f"{self.filename} does not exist, not appending"
+                )
+                append = False
 
         if append:
             self.print_info(f"appending to asciicast at {self.filename}")
         else:
-            self.print_info(f"recording asciicast to {self.filename}")
+            if self.filename == "-":
+                self.print_info(f"recording asciicast to stdout")
+            else:
+                self.print_info(f"recording asciicast to {self.filename}")
 
         if self.command:
             self.print_info("""exit opened program when you're done""")
@@ -118,6 +139,9 @@ def execute(self) -> int:
                 cols_override=self.cols_override,
                 rows_override=self.rows_override,
             )
+        except IOError as e:
+            self.print_error(f"I/O error: {str(e)}")
+            return 1
         except v2.LoadError:
             self.print_error(
                 "can only append to asciicast v2 format recordings"
@@ -155,7 +179,7 @@ def execute(self) -> int:
                     f"retry later by running: asciinema upload {self.filename}"
                 )
                 return 1
-        else:
+        elif self.filename != "-":
             self.print_info(f"asciicast saved to {self.filename}")
 
         return 0
diff --git a/asciinema/commands/upload.py b/asciinema/commands/upload.py
index 57af7e705..c721b7a50 100644
--- a/asciinema/commands/upload.py
+++ b/asciinema/commands/upload.py
@@ -25,9 +25,12 @@ def execute(self) -> int:
 
         except APIError as e:
             self.print_error(f"upload failed: {str(e)}")
-            self.print_error(
-                f"retry later by running: asciinema upload {self.filename}"
-            )
+
+            if e.retryable:
+                self.print_error(
+                    f"retry later by running: asciinema upload {self.filename}"
+                )
+
             return 1
 
         return 0
diff --git a/asciinema/config.py b/asciinema/config.py
index 51718a221..c30b18ca5 100644
--- a/asciinema/config.py
+++ b/asciinema/config.py
@@ -136,6 +136,10 @@ def record_prefix_key(self) -> Any:
     def record_pause_key(self) -> Any:
         return self.__get_key("record", "pause", "C-\\")
 
+    @property
+    def record_add_marker_key(self) -> Any:
+        return self.__get_key("record", "add_marker")
+
     @property
     def play_idle_time_limit(self) -> Optional[float]:
         fallback = self.config.getfloat(
@@ -157,6 +161,10 @@ def play_pause_key(self) -> Any:
     def play_step_key(self) -> Any:
         return self.__get_key("play", "step", ".")
 
+    @property
+    def play_next_marker_key(self) -> Any:
+        return self.__get_key("play", "next_marker", "]")
+
     @property
     def notifications_enabled(self) -> bool:
         return self.config.getboolean(
diff --git a/asciinema/file_writer.py b/asciinema/file_writer.py
index c07e2d9a9..b3a2d7d9c 100644
--- a/asciinema/file_writer.py
+++ b/asciinema/file_writer.py
@@ -1,5 +1,3 @@
-import os
-import stat
 from typing import IO, Any, Callable, Optional
 
 
@@ -29,15 +27,9 @@ def _open_file(self) -> None:
     def _write(self, data: Any) -> None:
         try:
             self.file.write(data)  # type: ignore
-        except BrokenPipeError as e:
-            if self.path != "-" and stat.S_ISFIFO(os.stat(self.path).st_mode):
-                self.on_error("Broken pipe, reopening...")
-                self._open_file()
-                self.on_error("Output pipe reopened successfully")
-                self.file.write(data)  # type: ignore
-            else:
-                self.on_error("Output pipe broken")
-                raise e
+        except IOError as e:
+            self.on_error("Write error, recording suspended")
+            raise e
 
 
 def noop(_: Any) -> None:
diff --git a/asciinema/player.py b/asciinema/player.py
index 341f65621..14aea3761 100644
--- a/asciinema/player.py
+++ b/asciinema/player.py
@@ -1,3 +1,4 @@
+import json
 import sys
 import time
 from typing import Any, Dict, Optional, TextIO, Union
@@ -7,6 +8,44 @@
 from .asciicast.v2 import Asciicast as v2
 from .tty_ import raw, read_blocking
 
+Header = Dict[str, Any]
+
+
+class RawOutput:
+    def __init__(self, stream: Optional[str]) -> None:
+        self.stream = stream or "o"
+
+    def start(self, _header: Header) -> None:
+        pass
+
+    def write(self, _time: float, event_type: str, data: str) -> None:
+        if event_type == self.stream:
+            sys.stdout.write(data)
+            sys.stdout.flush()
+
+
+class AsciicastOutput:
+    def __init__(self, stream: Optional[str]) -> None:
+        self.stream = stream
+
+    def start(self, header: Header) -> None:
+        self.__write_line(header)
+
+    def write(self, time: float, event_type: str, data: str) -> None:
+        if self.stream in [None, event_type]:
+            self.__write_line([time, event_type, data])
+
+    def __write_line(self, obj: Any) -> None:
+        line = json.dumps(
+            obj, ensure_ascii=False, indent=None, separators=(", ", ": ")
+        )
+
+        sys.stdout.write(f"{line}\r\n")
+        sys.stdout.flush()
+
+
+Output = Union[RawOutput, AsciicastOutput]
+
 
 class Player:  # pylint: disable=too-few-public-methods
     def play(
@@ -15,17 +54,39 @@ def play(
         idle_time_limit: Optional[int] = None,
         speed: float = 1.0,
         key_bindings: Optional[Dict[str, Any]] = None,
+        out_fmt: str = "raw",
+        stream: Optional[str] = None,
+        pause_on_markers: bool = False,
     ) -> None:
         if key_bindings is None:
             key_bindings = {}
+
+        output: Output = (
+            RawOutput(stream) if out_fmt == "raw" else AsciicastOutput(stream)
+        )
+
         try:
             with open("/dev/tty", "rt", encoding="utf-8") as stdin:
                 with raw(stdin.fileno()):
                     self._play(
-                        asciicast, idle_time_limit, speed, stdin, key_bindings
+                        asciicast,
+                        idle_time_limit,
+                        speed,
+                        stdin,
+                        key_bindings,
+                        output,
+                        pause_on_markers,
                     )
-        except Exception:  # pylint: disable=broad-except
-            self._play(asciicast, idle_time_limit, speed, None, key_bindings)
+        except IOError:
+            self._play(
+                asciicast,
+                idle_time_limit,
+                speed,
+                None,
+                key_bindings,
+                output,
+                False,
+            )
 
     @staticmethod
     def _play(  # pylint: disable=too-many-locals
@@ -34,63 +95,96 @@ def _play(  # pylint: disable=too-many-locals
         speed: float,
         stdin: Optional[TextIO],
         key_bindings: Dict[str, Any],
+        output: Output,
+        pause_on_markers: bool,
     ) -> None:
         idle_time_limit = idle_time_limit or asciicast.idle_time_limit
         pause_key = key_bindings.get("pause")
         step_key = key_bindings.get("step")
+        next_marker_key = key_bindings.get("next_marker")
 
-        stdout = asciicast.stdout_events()
-        stdout = ev.to_relative_time(stdout)
-        stdout = ev.cap_relative_time(stdout, idle_time_limit)
-        stdout = ev.to_absolute_time(stdout)
-        stdout = ev.adjust_speed(stdout, speed)
+        events = asciicast.events()
+        events = ev.to_relative_time(events)
+        events = ev.cap_relative_time(events, idle_time_limit)
+        events = ev.to_absolute_time(events)
+        events = ev.adjust_speed(events, speed)
+
+        output.start(asciicast.v2_header)
 
-        base_time = time.time()
         ctrl_c = False
-        paused = False
-        pause_time: Optional[float] = None
+        pause_elapsed_time: Optional[float] = None
+        events_iter = iter(events)
+        start_time = time.perf_counter()
 
-        for t, _type, text in stdout:
-            delay = t - (time.time() - base_time)
+        def wait(timeout: int) -> bytes:
+            if stdin is not None:
+                return read_blocking(stdin.fileno(), timeout)
 
-            while stdin and not ctrl_c and delay > 0:
-                if paused:
-                    while True:
-                        data = read_blocking(stdin.fileno(), 1000)
+            return b""
 
-                        if 0x03 in data:  # ctrl-c
-                            ctrl_c = True
-                            break
+        def next_event() -> Any:
+            try:
+                return events_iter.__next__()
+            except StopIteration:
+                return (None, None, None)
 
-                        if data == pause_key:
-                            paused = False
-                            assert pause_time is not None
-                            base_time += time.time() - pause_time
-                            break
+        time_, event_type, text = next_event()
 
-                        if data == step_key:
-                            delay = 0
-                            pause_time = time.time()
-                            base_time = pause_time - t
-                            break
-                else:
-                    data = read_blocking(stdin.fileno(), delay)
+        while time_ is not None and not ctrl_c:
+            if pause_elapsed_time:
+                while time_ is not None:
+                    key = wait(1000)
+
+                    if 0x03 in key:  # ctrl-c
+                        ctrl_c = True
+                        break
 
-                    if not data:
+                    if key == pause_key:
+                        assert pause_elapsed_time is not None
+                        start_time = time.perf_counter() - pause_elapsed_time
+                        pause_elapsed_time = None
                         break
 
-                    if 0x03 in data:  # ctrl-c
+                    if key == step_key:
+                        pause_elapsed_time = time_
+                        output.write(time_, event_type, text)
+                        time_, event_type, text = next_event()
+
+                    elif key == next_marker_key:
+                        while time_ is not None and event_type != "m":
+                            output.write(time_, event_type, text)
+                            time_, event_type, text = next_event()
+
+                        if time_ is not None:
+                            output.write(time_, event_type, text)
+                            pause_elapsed_time = time_
+                            time_, event_type, text = next_event()
+            else:
+                while time_ is not None:
+                    elapsed_wall_time = time.perf_counter() - start_time
+                    delay = time_ - elapsed_wall_time
+                    key = b""
+
+                    if delay > 0:
+                        key = wait(delay)
+
+                    if 0x03 in key:  # ctrl-c
                         ctrl_c = True
                         break
 
-                    if data == pause_key:
-                        paused = True
-                        pause_time = time.time()
-                        slept = t - (pause_time - base_time)
-                        delay = delay - slept
+                    elif key == pause_key:
+                        pause_elapsed_time = time.perf_counter() - start_time
+                        break
 
-            if ctrl_c:
-                break
+                    else:
+                        output.write(time_, event_type, text)
 
-            sys.stdout.write(text)
-            sys.stdout.flush()
+                        if event_type == "m" and pause_on_markers:
+                            pause_elapsed_time = time_
+                            time_, event_type, text = next_event()
+                            break
+
+                        time_, event_type, text = next_event()
+
+        if ctrl_c:
+            raise KeyboardInterrupt()
diff --git a/asciinema/pty_.py b/asciinema/pty_.py
index 2c62717c9..ce5ecd57e 100644
--- a/asciinema/pty_.py
+++ b/asciinema/pty_.py
@@ -1,5 +1,4 @@
 import array
-import errno
 import fcntl
 import os
 import pty
@@ -19,6 +18,8 @@
     signal.SIGQUIT,
 ]
 
+READ_LEN = 256 * 1024
+
 
 # pylint: disable=too-many-arguments,too-many-locals,too-many-statements
 def record(
@@ -37,6 +38,8 @@ def record(
     prefix_mode: bool = False
     prefix_key = key_bindings.get("prefix")
     pause_key = key_bindings.get("pause")
+    add_marker_key = key_bindings.get("add_marker")
+    input_data = bytes()
 
     def set_pty_size() -> None:
         cols, rows = get_tty_size()
@@ -44,13 +47,17 @@ def set_pty_size() -> None:
         fcntl.ioctl(pty_fd, termios.TIOCSWINSZ, buf)
 
     def handle_master_read(data: Any) -> None:
-        os.write(tty_stdout_fd, data)
+        remaining_data = memoryview(data)
+        while remaining_data:
+            n = os.write(tty_stdout_fd, remaining_data)
+            remaining_data = remaining_data[n:]
 
         if not pause_time:
             assert start_time is not None
-            writer.write_stdout(time.time() - start_time, data)
+            writer.write_stdout(time.perf_counter() - start_time, data)
 
     def handle_stdin_read(data: Any) -> None:
+        nonlocal input_data
         nonlocal pause_time
         nonlocal start_time
         nonlocal prefix_mode
@@ -59,93 +66,111 @@ def handle_stdin_read(data: Any) -> None:
             prefix_mode = True
             return
 
-        if prefix_mode or (not prefix_key and data in [pause_key]):
+        if prefix_mode or (
+            not prefix_key and data in [pause_key, add_marker_key]
+        ):
             prefix_mode = False
 
             if data == pause_key:
                 if pause_time:
                     assert start_time is not None
-                    start_time += time.time() - pause_time
+                    start_time += time.perf_counter() - pause_time
                     pause_time = None
                     notify("Resumed recording")
                 else:
-                    pause_time = time.time()
+                    pause_time = time.perf_counter()
                     notify("Paused recording")
 
+            elif data == add_marker_key:
+                assert start_time is not None
+                writer.write_marker(time.perf_counter() - start_time)
+                notify("Marker added")
+
             return
 
-        remaining_data = data
-        while remaining_data:
-            n = os.write(pty_fd, remaining_data)
-            remaining_data = remaining_data[n:]
+        input_data += data
 
-        if not pause_time:
+        # save stdin unless paused or data is OSC response (e.g. \x1b]11;?\x07)
+        if not pause_time and not (
+            len(data) > 2
+            and data[0] == 0x1B
+            and data[1] == 0x5D
+            and data[-1] == 0x07
+        ):
             assert start_time is not None
-            writer.write_stdin(time.time() - start_time, data)
+            writer.write_stdin(time.perf_counter() - start_time, data)
 
     def copy(signal_fd: int) -> None:  # pylint: disable=too-many-branches
-        fds = [pty_fd, tty_stdin_fd, signal_fd]
-        stdin_fd = pty.STDIN_FILENO
+        nonlocal input_data
 
-        if not os.isatty(stdin_fd):
-            fds.append(stdin_fd)
+        crfds = [pty_fd, tty_stdin_fd, signal_fd]
 
         while True:
+            if len(input_data) > 0:
+                cwfds = [pty_fd]
+            else:
+                cwfds = []
+
             try:
-                rfds, _, _ = select.select(fds, [], [])
-            except OSError as e:  # Python >= 3.3
-                if e.errno == errno.EINTR:
-                    continue
+                rfds, wfds, _ = select.select(crfds, cwfds, [])
+            except KeyboardInterrupt:
+                if tty_stdin_fd in crfds:
+                    crfds.remove(tty_stdin_fd)
+
+                break
 
             if pty_fd in rfds:
-                data = os.read(pty_fd, 1024)
+                try:
+                    data = os.read(pty_fd, READ_LEN)
+                except OSError as e:
+                    data = b""
 
                 if not data:  # Reached EOF.
-                    fds.remove(pty_fd)
+                    break
                 else:
                     handle_master_read(data)
 
             if tty_stdin_fd in rfds:
-                data = os.read(tty_stdin_fd, 1024)
-
-                if not data:
-                    fds.remove(tty_stdin_fd)
-                else:
-                    handle_stdin_read(data)
-
-            if stdin_fd in rfds:
-                data = os.read(stdin_fd, 1024)
+                data = os.read(tty_stdin_fd, READ_LEN)
 
                 if not data:
-                    fds.remove(stdin_fd)
+                    if tty_stdin_fd in crfds:
+                        crfds.remove(tty_stdin_fd)
                 else:
                     handle_stdin_read(data)
 
             if signal_fd in rfds:
-                data = os.read(signal_fd, 1024)
+                data = os.read(signal_fd, READ_LEN)
 
                 if data:
                     signals = struct.unpack(f"{len(data)}B", data)
 
                     for sig in signals:
                         if sig in EXIT_SIGNALS:
-                            os.close(pty_fd)
-                            return None
+                            crfds.remove(signal_fd)
                         if sig == signal.SIGWINCH:
                             set_pty_size()
 
+            if pty_fd in wfds:
+                n = os.write(pty_fd, input_data)
+                input_data = input_data[n:]
+
     pid, pty_fd = pty.fork()
 
     if pid == pty.CHILD:
         os.execvpe(command[0], command, env)
 
-    start_time = time.time()
+    flags = fcntl.fcntl(pty_fd, fcntl.F_GETFL, 0) | os.O_NONBLOCK
+    fcntl.fcntl(pty_fd, fcntl.F_SETFL, flags)
+
+    start_time = time.perf_counter()
     set_pty_size()
 
     with SignalFD(EXIT_SIGNALS + [signal.SIGWINCH]) as sig_fd:
         with raw(tty_stdin_fd):
             try:
                 copy(sig_fd)
+                os.close(pty_fd)
             except (IOError, OSError):
                 pass
 
diff --git a/asciinema/recorder.py b/asciinema/recorder.py
index 14f27e5ec..c6814aba9 100644
--- a/asciinema/recorder.py
+++ b/asciinema/recorder.py
@@ -1,4 +1,5 @@
 import os
+import sys
 import time
 from typing import Any, Callable, Dict, List, Optional, TextIO, Tuple, Type
 
@@ -77,22 +78,15 @@ def __init__(self) -> None:
         self.stdout_file: Optional[TextIO] = None
 
     def __enter__(self) -> Tuple[int, int]:
-        try:
-            self.stdin_file = open("/dev/tty", "rt", encoding="utf_8")
-        except OSError:
-            self.stdin_file = open("/dev/null", "rt", encoding="utf_8")
-
         try:
             self.stdout_file = open("/dev/tty", "wt", encoding="utf_8")
         except OSError:
             self.stdout_file = open("/dev/null", "wt", encoding="utf_8")
 
-        return (self.stdin_file.fileno(), self.stdout_file.fileno())
+        return (sys.stdin.fileno(), self.stdout_file.fileno())
 
     def __exit__(self, type_: str, value: str, traceback: str) -> None:
-        assert self.stdin_file is not None
         assert self.stdout_file is not None
-        self.stdin_file.close()
         self.stdout_file.close()
 
 
@@ -137,17 +131,26 @@ def write_stdin(self, ts: float, data: Any) -> None:
     def write_stdout(self, ts: float, data: Any) -> None:
         self.enqueue([ts, "o", data])
 
+    def write_marker(self, ts: float) -> None:
+        self.enqueue([ts, "m", None])
+
     def run(self) -> None:
-        with self.writer as w:
-            event: Tuple[float, str, Any]
+        try:
+            with self.writer as w:
+                event: Tuple[float, str, Any]
+                for event in iter(self.queue.get, None):
+                    assert event is not None
+                    ts, etype, data = event
+
+                    if etype == "o":
+                        w.write_stdout(self.time_offset + ts, data)
+                    elif etype == "i":
+                        w.write_stdin(self.time_offset + ts, data)
+                    elif etype == "m":
+                        w.write_marker(self.time_offset + ts)
+        except IOError:
             for event in iter(self.queue.get, None):
-                assert event is not None
-                ts, etype, data = event
-
-                if etype == "o":
-                    w.write_stdout(self.time_offset + ts, data)
-                elif etype == "i":
-                    w.write_stdin(self.time_offset + ts, data)
+                pass
 
 
 class async_notifier(async_worker):
diff --git a/asciinema/urllib_http_adapter.py b/asciinema/urllib_http_adapter.py
index de80fe36e..d6965323b 100644
--- a/asciinema/urllib_http_adapter.py
+++ b/asciinema/urllib_http_adapter.py
@@ -33,7 +33,7 @@ def iter(
         yield body's chunk as bytes
         """
         encoder = codecs.getencoder("utf-8")
-        for (key, value) in fields.items():
+        for key, value in fields.items():
             key = self.u(key)
             yield encoder(f"--{self.boundary}\r\n")
             yield encoder(
@@ -44,7 +44,7 @@ def iter(
                 value = str(value)
             yield encoder(self.u(value))
             yield encoder("\r\n")
-        for (key, filename_and_f) in files.items():
+        for key, filename_and_f in files.items():
             filename, f = filename_and_f
             key = self.u(key)
             filename = self.u(filename)
diff --git a/doc/asciicast-v2.md b/doc/asciicast-v2.md
index ff7dddb34..e7f6e8243 100644
--- a/doc/asciicast-v2.md
+++ b/doc/asciicast-v2.md
@@ -13,7 +13,8 @@ Example file:
 {"version": 2, "width": 80, "height": 24, "timestamp": 1504467315, "title": "Demo", "env": {"TERM": "xterm-256color", "SHELL": "/bin/zsh"}}
 [0.248848, "o", "\u001b[1;31mHello \u001b[32mWorld!\u001b[0m\n"]
 [1.001376, "o", "That was ok\rThis is better."]
-[2.143733, "o", " "]
+[1.500000, "m", ""]
+[2.143733, "o", "Now... "]
 [6.541828, "o", "Bye!"]
 ```
 
@@ -114,7 +115,7 @@ Where:
 
 * `time` (float) - indicates when this event happened, represented as the number
   of seconds since the beginning of the recording session,
-* `event-type` (string) - one of: `"o"`, `"i"`,
+* `event-type` (string) - one of: `"o"`, `"i"`, `"m"`
 * `event-data` (any) - event specific data, described separately for each event
   type.
 
@@ -125,7 +126,7 @@ For example, let's look at the following line:
 It represents the event which:
 
 * happened 1.001376 sec after the start of the recording session,
-* is of type `"o"` (print to stdout, see below),
+* is of type `"o"` (output, write to a terminal, see below),
 * has data `"Hello world"`.
 
 ### Supported event types
@@ -140,29 +141,41 @@ A tool which interprets the event stream (web/cli player, post-processor) should
 ignore (or pass through) event types it doesn't understand or doesn't care
 about.
 
-#### "o" - data written to stdout
+#### "o" - output, data written to the terminal
 
 Event of type `"o"` represents printing new data to terminal's stdout.
 
-`event-data` is a string containing the data that was printed to a terminal. It
-has to be valid, UTF-8 encoded JSON string as described
-in [JSON RFC section 2.5](http://www.ietf.org/rfc/rfc4627.txt), with all
-non-printable Unicode codepoints encoded as `\uXXXX`.
+`event-data` is a string containing the data that was printed. It must be valid,
+UTF-8 encoded JSON string as described in [JSON RFC section
+2.5](http://www.ietf.org/rfc/rfc4627.txt), with any non-printable Unicode
+codepoints encoded as `\uXXXX`.
 
-#### "i" - data read from stdin
+#### "i" - input, data read from the terminal
 
-Event of type `"i"` represents character(s) typed in by the user, or
-more specifically, data sent from terminal emulator to stdin of the recorded
-shell.
+Event of type `"i"` represents character typed in by the user, or more
+specifically, raw data sent from a terminal emulator to stdin of the recorded
+program (usually shell).
 
-`event-data` is a string containing the captured character(s). Like with `"o"`
-event, it's UTF-8 encoded JSON string, with all non-printable Unicode codepoints
-encoded as `\uXXXX`.
+`event-data` is a string containing captured ASCII character representing a key,
+or a control character like `"\r"` (enter), `"\u0001"` (ctrl-a), `"\u0003"`
+(ctrl-c), etc. Like with `"o"` event, it's UTF-8 encoded JSON string, with any
+non-printable Unicode codepoints encoded as `\uXXXX`.
 
-> Official asciinema recorder doesn't capture stdin by default. All
+> Official asciinema recorder doesn't capture keyboard input by default. All
 > implementations of asciicast-compatible terminal recorder should not capture
 > it either unless explicitly permitted by the user.
 
+#### "m" - marker
+
+Event of type `"m"` represents a marker.
+
+When marker is encountered in the event stream and "pause on markers"
+functionality of the player is enabled, the playback should pause, and wait for
+the user to resume.
+
+`event-data` can be used to annotate a marker. Annotations may be used to e.g.
+show a list of named "chapters".
+
 ## Notes on compatibility
 
 Version 2 of asciicast file format solves several problems which couldn't be
diff --git a/man/asciinema.1.md b/man/asciinema.1.md
index ea42d41a1..54e1aa16d 100644
--- a/man/asciinema.1.md
+++ b/man/asciinema.1.md
@@ -331,10 +331,7 @@ More documentation is available on the asciicast.org website and its GitHub wiki
 
 * Web:  [asciinema.org/docs/](https://asciinema.org/docs/)
 * Wiki: [github.com/asciinema/asciinema/wiki](https://github.com/asciinema/asciinema/wiki)
-<<<<<<< HEAD
-=======
 * IRC:  [Channel on Libera.Chat](https://web.libera.chat/gamja/#asciinema)
->>>>>>> develop
 * Twitter: [@asciinema](https://twitter.com/asciinema)
 
 
diff --git a/setup.cfg b/setup.cfg
index 7e75a81df..b21514bb1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = asciinema
-version = 2.2.0
+version = 2.3.0
 author = Marcin Kulik
 author_email = m@ku1ik.com
 url = https://asciinema.org
@@ -20,11 +20,11 @@ classifiers =
     License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
     Natural Language :: English
     Programming Language :: Python
-    Programming Language :: Python :: 3.6
     Programming Language :: Python :: 3.7
     Programming Language :: Python :: 3.8
     Programming Language :: Python :: 3.9
     Programming Language :: Python :: 3.10
+    Programming Language :: Python :: 3.11
     Topic :: System :: Shells
     Topic :: Terminals
     Topic :: Utilities


Test patch:
diff --git a/tests/integration.sh b/tests/integration.sh
index 9f4f5d1bd..57dcde216 100755
--- a/tests/integration.sh
+++ b/tests/integration.sh
@@ -23,6 +23,10 @@ asciinema() {
     python3 -m asciinema "${@}"
 }
 
+## disable notifications
+
+printf "[notifications]\nenabled = no\n" >> "${ASCIINEMA_CONFIG_HOME}/config"
+
 ## test help message
 
 asciinema -h
@@ -93,3 +97,8 @@ asciinema rec --raw -c 'bash -c "echo t3st; sleep 1; echo ok"' "${TMP_DATA_DIR}/
 # appending to existing recording
 asciinema rec -c 'echo allright!; sleep 0.1' "${TMP_DATA_DIR}/7.cast"
 asciinema rec --append -c uptime "${TMP_DATA_DIR}/7.cast"
+
+# adding a marker
+printf "[record]\nadd_marker_key = C-b\n" >> "${ASCIINEMA_CONFIG_HOME}/config"
+(bash -c "sleep 1; printf '.'; sleep 0.5; printf '\x08'; sleep 0.5; printf '\x02'; sleep 0.5; printf '\x04'") | asciinema rec -c /bin/bash "${TMP_DATA_DIR}/8.cast"
+grep '"m",' "${TMP_DATA_DIR}/8.cast"