Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion addon/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 2,
"name": "Thunderbird MCP Bridge",
"version": "1.2.0",
"version": "1.2.1",
"description": "Lets a local MCP server drive this Thunderbird: mail, folders, contacts, calendar, filters and settings.",
"author": "thunderbird-mcp",
"browser_specific_settings": {
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "thunderbird-mcp"
version = "1.2.0"
version = "1.2.1"
description = "MCP server that drives Thunderbird — mail, folders, contacts, calendar, filters, and settings"
readme = "README.md"
requires-python = ">=3.11"
Expand Down
13 changes: 12 additions & 1 deletion src/tbmcp/addon_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,18 @@ def _launch(exe: pathlib.Path, extra_args: list[str], profile: ThunderbirdProfil
argv = [str(exe), *extra_args]
if profile is not None:
argv += ["-profile", str(profile.path)]
kwargs: dict = {"stdin": subprocess.DEVNULL, "close_fds": True}
# All three streams, not just stdin. Thunderbird outlives us on purpose, so an
# inherited stdout keeps the caller's pipe open long after `install-addon` has
# finished — an agent or CI step capturing our output waits for the mail client
# to be closed, and its own timeout cannot rescue it, because killing us leaves
# the grandchild holding the pipe. `close_fds` does not cover the std handles;
# they have to be redirected explicitly, as the daemon spawn in bridge.py does.
kwargs: dict = {
"stdin": subprocess.DEVNULL,
"stdout": subprocess.DEVNULL,
"stderr": subprocess.DEVNULL,
"close_fds": True,
}
if sys.platform == "win32":
kwargs["creationflags"] = 0x00000008 | 0x00000200 # DETACHED | NEW_GROUP
else:
Expand Down
2 changes: 1 addition & 1 deletion src/tbmcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,4 @@ def _version() -> str:

return version("thunderbird-mcp")
except Exception:
return "1.2.0"
return "1.2.1"
59 changes: 59 additions & 0 deletions tests/test_addon_install_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Launching Thunderbird must not hold the caller's stdout open.

`_launch` starts a process that outlives us by design. If it lets that process
inherit our stdout, every caller that captures output — an agent, a CI step, a
shell pipeline — blocks until Thunderbird is closed, long after `install-addon`
has finished its work. A timeout does not rescue them: killing the direct child
leaves the grandchild holding the pipe.

The daemon spawn in `bridge.py` already redirects all three streams; this is the
same requirement one file over.
"""

from __future__ import annotations

import subprocess
import sys
import time

# Long enough that a regression is unmistakable against the deadline below, short
# enough that a failing run still ends on its own.
SLEEP_SECONDS = 25
DEADLINE_SECONDS = 10

HELPER = '''
import sys
from tbmcp.addon_install import _launch


class Exe:
"""`_launch` stringifies the exe and appends the extra args."""

def __str__(self):
return sys.executable


_launch(Exe(), ["-c", "import time; time.sleep({sleep})"], None)
print("launched", flush=True)
'''


def test_launch_does_not_hold_the_callers_stdout(tmp_path):
helper = tmp_path / "launch_helper.py"
helper.write_text(HELPER.format(sleep=SLEEP_SECONDS), encoding="utf-8")

started = time.monotonic()
done = subprocess.run(
[sys.executable, str(helper)],
capture_output=True,
text=True,
timeout=SLEEP_SECONDS + 60,
)
elapsed = time.monotonic() - started

assert done.returncode == 0, done.stderr
assert "launched" in done.stdout
assert elapsed < DEADLINE_SECONDS, (
f"the caller was held for {elapsed:.1f}s after the helper exited: the launched "
"process inherited its stdout"
)
2 changes: 1 addition & 1 deletion tests/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from tbmcp import server

ROOT = pathlib.Path(__file__).resolve().parents[1]
EXPECTED = "1.2.0"
EXPECTED = "1.2.1"


def _pyproject_version() -> str:
Expand Down
Loading