cdx-manager 0.18.4 → 0.18.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ """Replacing a companion that is currently running.
2
+
3
+ `cdx update` used to leave the running tray on its old binary and tell the user
4
+ to quit it from its menu. That is a correct thing to say and a poor thing to
5
+ have to do: the whole point of the companion is that nobody thinks about it.
6
+
7
+ The order here is what makes it safe, and it is the reverse of what looks
8
+ natural. The running companion is asked to stop *before* anything on disk moves,
9
+ so the replacement never has to fight a live process for its own files — on
10
+ Windows a running executable cannot be replaced at all, and a directory holding
11
+ one cannot reliably be renamed either. Stop, replace, start, and if the start
12
+ fails, put back the one that was proven to work and start that instead.
13
+
14
+ Nothing here kills anything. A companion that does not stop within the bounded
15
+ wait is left alone and reported, because a tray the user is reading a menu from
16
+ is not a process to terminate over a version number.
17
+ """
18
+ import os
19
+ import time
20
+
21
+ from .tray_instance import _is_alive, companion_instance, lock_path
22
+
23
+ # How long to wait for a companion to notice the request and exit.
24
+ #
25
+ # It polls several times a second, so this is generous by two orders of
26
+ # magnitude — deliberately. On Windows the menu is modal: while the user has it
27
+ # open, the message pump is stopped and the companion notices nothing at all.
28
+ # Ten seconds covers reading a menu and closing it; beyond that, the honest
29
+ # answer is to leave it running and say so.
30
+ STOP_TIMEOUT_SECONDS = 10.0
31
+ _POLL_SECONDS = 0.1
32
+
33
+
34
+ def stop_path(env=None):
35
+ """Where a stop request is written. Beside the lock, by the same rule."""
36
+ return os.path.join(os.path.dirname(lock_path(env=env)), "companion.stop")
37
+
38
+
39
+ def request_stop(env=None):
40
+ """Ask the running companion to exit. False when it could not be asked."""
41
+ path = stop_path(env=env)
42
+ try:
43
+ os.makedirs(os.path.dirname(path), exist_ok=True)
44
+ with open(path, "w", encoding="utf-8") as handle:
45
+ handle.write("stop")
46
+ return True
47
+ except OSError:
48
+ return False
49
+
50
+
51
+ def clear_stop(env=None):
52
+ """Remove a stop request nobody consumed.
53
+
54
+ A companion that had already died leaves the request on disk, and the next
55
+ one to start would read it and quit immediately — a tray that refuses to
56
+ stay open, with nothing on screen explaining why.
57
+ """
58
+ try:
59
+ os.remove(stop_path(env=env))
60
+ except OSError:
61
+ pass
62
+
63
+
64
+ def wait_for_exit(pid, timeout=STOP_TIMEOUT_SECONDS, sleep=None, alive=None):
65
+ """Wait for one process to go, up to `timeout`. True if it did."""
66
+ sleep = time.sleep if sleep is None else sleep
67
+ alive = _is_alive if alive is None else alive
68
+ deadline = time.monotonic() + timeout
69
+ while True:
70
+ if not alive(pid):
71
+ return True
72
+ if time.monotonic() >= deadline:
73
+ return False
74
+ sleep(_POLL_SECONDS)
75
+
76
+
77
+ def stop_running_companion(env=None, timeout=STOP_TIMEOUT_SECONDS, sleep=None, alive=None):
78
+ """Stop the companion if one is running, and say what happened.
79
+
80
+ `stopped` is True only when a live companion was asked and observed to go.
81
+ Everything else is a reason, and every reason is one the caller can act on
82
+ without guessing: nothing was running, the request could not be written, or
83
+ it did not exit in time.
84
+ """
85
+ instance = companion_instance(env=env)
86
+ pid = instance.get("pid")
87
+ if not pid:
88
+ # A stale lock is not a running tray, but it is worth clearing the stop
89
+ # request that may be sitting beside it from an earlier attempt.
90
+ clear_stop(env=env)
91
+ return {"stopped": False, "was_running": False, "reason": "no tray companion is running"}
92
+ if not request_stop(env=env):
93
+ return {
94
+ "stopped": False,
95
+ "was_running": True,
96
+ "pid": pid,
97
+ "reason": "the stop request could not be written",
98
+ }
99
+ if not wait_for_exit(pid, timeout=timeout, sleep=sleep, alive=alive):
100
+ # It is still running and it still owns its binary. Withdrawing the
101
+ # request matters: leaving it would stop the companion later, at a
102
+ # moment nobody connected to an update.
103
+ clear_stop(env=env)
104
+ return {
105
+ "stopped": False,
106
+ "was_running": True,
107
+ "pid": pid,
108
+ "reason": (
109
+ f"the running tray did not stop within {timeout:g}s. "
110
+ "An open tray menu blocks it on Windows; close the menu and run: cdx tray launch"
111
+ ),
112
+ }
113
+ clear_stop(env=env)
114
+ return {"stopped": True, "was_running": True, "pid": pid}
115
+
116
+
117
+ def start_companion(executable, spawn=None, verify=None, env=None, settle=None):
118
+ """Start a companion and confirm it is actually running.
119
+
120
+ Spawning is not evidence. A binary that exits immediately — a missing
121
+ library, a quarantined bundle, a companion that reads a stale stop request —
122
+ leaves a successful spawn and no tray, which is exactly the "reported
123
+ success before the replacement is usable" this must not do.
124
+ """
125
+ from .tray_install import launch_command
126
+
127
+ spawn = spawn or _spawn_detached
128
+ settle = time.sleep if settle is None else settle
129
+ try:
130
+ spawn(launch_command(executable))
131
+ except Exception as error: # noqa: BLE001 - any launch failure is a reason
132
+ return {"started": False, "reason": str(error)}
133
+ # It has to register its lock before it counts as running, and it does that
134
+ # a moment after exec.
135
+ for _ in range(20):
136
+ settle(_POLL_SECONDS)
137
+ instance = (verify or companion_instance)(env=env)
138
+ if instance.get("pid"):
139
+ return {"started": True, "pid": instance["pid"]}
140
+ return {"started": False, "reason": "it was launched but never registered as running"}
141
+
142
+
143
+ def _spawn_detached(command):
144
+ import subprocess
145
+
146
+ subprocess.Popen( # noqa: S603 (path comes from our own install state)
147
+ command,
148
+ stdout=subprocess.DEVNULL,
149
+ stderr=subprocess.DEVNULL,
150
+ stdin=subprocess.DEVNULL,
151
+ start_new_session=True,
152
+ )
@@ -0,0 +1,80 @@
1
+ """Which terminal a tray session row opens.
2
+
3
+ The companion picks one per platform today: Terminal.app on macOS, the first of
4
+ four names that answers on Linux. Someone who works in iTerm or Ghostty gets a
5
+ second terminal application opened beside the one they were already in, which is
6
+ the opposite of the shortcut the row is meant to be.
7
+
8
+ What is stored is an application, never a command, and that restriction is the
9
+ whole security argument. A preference that could hold a command line would be a
10
+ way to run anything, in a process the user did not start, triggered by clicking
11
+ a menu row — and it would be writable by anything that can write CDX's state.
12
+ The validation below is what makes "open my terminal" impossible to turn into
13
+ "run this". Nothing here reaches a shell: the value names an application and the
14
+ platform's own launcher resolves it.
15
+ """
16
+ import json
17
+ import os
18
+ import re
19
+
20
+ STATE_VERSION = 1
21
+ # An application name or a bundle identifier, and nothing that could be read as
22
+ # a command. No slashes, so it cannot name a path; no quotes, semicolons,
23
+ # ampersands, pipes, backticks or dollars, so it cannot carry a shell fragment;
24
+ # it must start with a letter or a digit, so it cannot be read as a flag.
25
+ _NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}$")
26
+
27
+ REFUSAL = (
28
+ "A terminal preference is an application name, not a command: "
29
+ "letters, digits, spaces, dots, dashes and underscores only. "
30
+ 'Examples: "iTerm", "Ghostty", "WezTerm", "kitty", "wt".'
31
+ )
32
+
33
+
34
+ def state_path(base_dir):
35
+ return os.path.join(base_dir, "tray", "terminal.json")
36
+
37
+
38
+ def terminal_preference(base_dir):
39
+ """The chosen terminal, or None for the platform's default.
40
+
41
+ Anything unreadable, unrecognised, or no longer valid reads as no
42
+ preference. A value that fails validation today must not be honoured just
43
+ because it was written before the rule existed.
44
+ """
45
+ try:
46
+ with open(state_path(base_dir), encoding="utf-8") as handle:
47
+ state = json.load(handle)
48
+ except (OSError, ValueError):
49
+ return None
50
+ if not isinstance(state, dict) or state.get("version") != STATE_VERSION:
51
+ return None
52
+ name = state.get("terminal")
53
+ return name if isinstance(name, str) and _NAME.match(name) else None
54
+
55
+
56
+ def valid_terminal(name):
57
+ return isinstance(name, str) and bool(_NAME.match(name))
58
+
59
+
60
+ def set_terminal(base_dir, name):
61
+ """Record a preferred terminal. Raises ValueError on anything else."""
62
+ if not valid_terminal(name):
63
+ raise ValueError(REFUSAL)
64
+ _write(base_dir, {"version": STATE_VERSION, "terminal": name})
65
+ return name
66
+
67
+
68
+ def clear_terminal(base_dir):
69
+ """Return to the platform's default."""
70
+ _write(base_dir, {"version": STATE_VERSION, "terminal": None})
71
+ return None
72
+
73
+
74
+ def _write(base_dir, state):
75
+ path = state_path(base_dir)
76
+ os.makedirs(os.path.dirname(path), exist_ok=True)
77
+ temp = f"{path}.{os.getpid()}.tmp"
78
+ with open(temp, "w", encoding="utf-8") as handle:
79
+ json.dump(state, handle, indent=2)
80
+ os.replace(temp, path)
@@ -171,6 +171,7 @@ def build_update_plan(target_version=None, package_root=None, env=None, prefix=N
171
171
  return {
172
172
  "mode": mode,
173
173
  "package_root": str(root),
174
+ "prefix": str(prefix or sys.prefix),
174
175
  "target_version": version,
175
176
  "steps": steps,
176
177
  }