1
- import os
2
- import signal
3
1
import subprocess
4
- import sys
5
2
import time
6
- from collections .abc import Iterator , Mapping , Sequence
3
+ from collections .abc import Iterator , Mapping
7
4
from contextlib import contextmanager , nullcontext
8
5
from pathlib import Path
9
6
from tempfile import NamedTemporaryFile
10
7
from typing import Optional , TypeVar
11
8
12
9
import click
13
- import psutil
14
10
import yaml
15
11
16
12
from dagster_dg .cli .global_options import dg_global_options
17
13
from dagster_dg .config import normalize_cli_config
18
14
from dagster_dg .context import DgContext
19
15
from dagster_dg .error import DgError
20
- from dagster_dg .utils import DgClickCommand , exit_with_error , pushd
16
+ from dagster_dg .utils import (
17
+ DgClickCommand ,
18
+ exit_with_error ,
19
+ get_venv_executable ,
20
+ pushd ,
21
+ )
22
+ from dagster_dg .utils .ipc import (
23
+ get_ipc_shutdown_pipe ,
24
+ interrupt_on_ipc_shutdown_message ,
25
+ open_ipc_subprocess ,
26
+ send_ipc_shutdown_message ,
27
+ )
21
28
22
29
T = TypeVar ("T" )
23
30
69
76
show_default = True ,
70
77
required = False ,
71
78
)
79
+ @click .option (
80
+ "--shutdown-pipe" ,
81
+ type = click .INT ,
82
+ required = False ,
83
+ hidden = True ,
84
+ help = (
85
+ "Internal use only. Pass a readable pipe file descriptor to the dg dev process"
86
+ " that will be monitored for a shutdown signal. Useful to interrupt the process in CI."
87
+ ),
88
+ )
72
89
@dg_global_options
73
90
@click .pass_context
74
91
def dev_command (
@@ -79,6 +96,7 @@ def dev_command(
79
96
port : Optional [int ],
80
97
host : Optional [str ],
81
98
live_data_poll_rate : int ,
99
+ shutdown_pipe : Optional [int ],
82
100
** global_options : Mapping [str , object ],
83
101
) -> None :
84
102
"""Start a local deployment of your Dagster project.
@@ -99,16 +117,45 @@ def dev_command(
99
117
* _format_forwarded_option ("--live-data-poll-rate" , live_data_poll_rate ),
100
118
]
101
119
120
+ read_fd , write_fd = get_ipc_shutdown_pipe ()
121
+ shutdown_pipe_options = ["--shutdown-pipe" , str (read_fd )]
122
+
123
+ other_options = [* shutdown_pipe_options , * forward_options ]
124
+
102
125
# In a code location context, we can just run `dagster dev` directly, using `dagster` from the
103
126
# code location's environment.
104
127
if dg_context .is_code_location :
105
128
cmd_location = dg_context .get_executable ("dagster" )
106
129
if dg_context .use_dg_managed_environment :
107
- cmd = ["uv" , "run" , "dagster" , "dev" , * forward_options ]
130
+ cmd = ["uv" , "run" , "dagster" , "dev" , * other_options ]
108
131
else :
109
- cmd = [cmd_location , "dev" , * forward_options ]
132
+ cmd = [cmd_location , "dev" , * other_options ]
110
133
temp_workspace_file_cm = nullcontext ()
111
134
135
+ # In a deployment context with a venv containing dagster and dagster-webserver (both are
136
+ # required for `dagster dev`), we can run `dagster dev` using whatever is installed in the
137
+ # deployment venv.
138
+ elif (
139
+ dg_context .is_deployment
140
+ and dg_context .has_venv
141
+ and dg_context .has_executable ("dagster" )
142
+ and dg_context .has_executable ("dagster-webserver" )
143
+ ):
144
+ # --no-project because we might not have the necessary fields in deployment pyproject.toml
145
+ cmd = [
146
+ "uv" ,
147
+ "run" ,
148
+ # Unclear why this is necessary, but it seems to be in CI. May be a uv version issue.
149
+ "--python" ,
150
+ get_venv_executable (dg_context .venv_path ),
151
+ "--no-project" ,
152
+ "dagster" ,
153
+ "dev" ,
154
+ * other_options ,
155
+ ]
156
+ cmd_location = dg_context .get_executable ("dagster" )
157
+ temp_workspace_file_cm = _temp_workspace_file (dg_context )
158
+
112
159
# In a deployment context, dg dev will construct a temporary
113
160
# workspace file that points at all defined code locations and invoke:
114
161
#
@@ -127,7 +174,7 @@ def dev_command(
127
174
"dagster-webserver" ,
128
175
"dagster" ,
129
176
"dev" ,
130
- * forward_options ,
177
+ * other_options ,
131
178
]
132
179
cmd_location = "ephemeral dagster dev"
133
180
temp_workspace_file_cm = _temp_workspace_file (dg_context )
@@ -138,32 +185,32 @@ def dev_command(
138
185
print (f"Using { cmd_location } " ) # noqa: T201
139
186
if workspace_file : # only non-None deployment context
140
187
cmd .extend (["--workspace" , workspace_file ])
141
- uv_run_dagster_dev_process = _open_subprocess (cmd )
142
- try :
143
- while True :
144
- time .sleep (_CHECK_SUBPROCESS_INTERVAL )
145
- if uv_run_dagster_dev_process .poll () is not None :
146
- raise DgError (
147
- f"dagster-dev process shut down unexpectedly with return code { uv_run_dagster_dev_process .returncode } ."
148
- )
149
- except KeyboardInterrupt :
150
- click .secho (
151
- "Received keyboard interrupt. Shutting down dagster-dev process." , fg = "yellow"
152
- )
153
- finally :
154
- # For reasons not fully understood, directly interrupting the `uv run` process does not
155
- # work as intended. The interrupt signal is not correctly propagated to the `dagster
156
- # dev` process, and so that process never shuts down. Therefore, we send the signal
157
- # directly to the `dagster dev` process (the only child of the `uv run` process). This
158
- # will cause `dagster dev` to terminate which in turn will cause `uv run` to terminate.
159
- dagster_dev_pid = _get_child_process_pid (uv_run_dagster_dev_process )
160
- _interrupt_subprocess (dagster_dev_pid )
161
-
188
+ uv_run_dagster_dev_process = open_ipc_subprocess (cmd , pass_fds = [read_fd ])
189
+ with interrupt_on_ipc_shutdown_message (shutdown_pipe ) if shutdown_pipe else nullcontext ():
162
190
try :
163
- uv_run_dagster_dev_process .wait (timeout = 10 )
164
- except subprocess .TimeoutExpired :
165
- click .secho ("`dagster dev` did not terminate in time. Killing it." )
166
- uv_run_dagster_dev_process .kill ()
191
+ while True :
192
+ time .sleep (_CHECK_SUBPROCESS_INTERVAL )
193
+ if uv_run_dagster_dev_process .poll () is not None :
194
+ raise DgError (
195
+ f"dagster-dev process shut down unexpectedly with return code { uv_run_dagster_dev_process .returncode } ."
196
+ )
197
+ except KeyboardInterrupt :
198
+ click .secho (
199
+ "Received keyboard interrupt. Shutting down dagster-dev process." , fg = "yellow"
200
+ )
201
+ finally :
202
+ # For reasons not fully understood, directly interrupting the `uv run` process does not
203
+ # work as intended. The interrupt signal is not correctly propagated to the `dagster
204
+ # dev` process, and so that process never shuts down. Therefore, we send the signal
205
+ # directly to the `dagster dev` process (the only child of the `uv run` process). This
206
+ # will cause `dagster dev` to terminate which in turn will cause `uv run` to terminate.
207
+ send_ipc_shutdown_message (write_fd )
208
+
209
+ try :
210
+ uv_run_dagster_dev_process .wait (timeout = 10 )
211
+ except subprocess .TimeoutExpired :
212
+ click .secho ("`dagster dev` did not terminate in time. Killing it." )
213
+ uv_run_dagster_dev_process .kill ()
167
214
168
215
169
216
@contextmanager
@@ -188,34 +235,3 @@ def _temp_workspace_file(dg_context: DgContext) -> Iterator[str]:
188
235
189
236
def _format_forwarded_option (option : str , value : object ) -> list [str ]:
190
237
return [] if value is None else [option , str (value )]
191
-
192
-
193
- def _get_child_process_pid (proc : "subprocess.Popen" ) -> int :
194
- children = psutil .Process (proc .pid ).children (recursive = False )
195
- if len (children ) != 1 :
196
- raise ValueError (f"Expected exactly one child process, but found { len (children )} " )
197
- return children [0 ].pid
198
-
199
-
200
- # Windows subprocess termination utilities. See here for why we send CTRL_BREAK_EVENT on Windows:
201
- # https://stefan.sofa-rockers.org/2013/08/15/handling-sub-process-hierarchies-python-linux-os-x/
202
-
203
-
204
- def _interrupt_subprocess (pid : int ) -> None :
205
- """Send CTRL_BREAK_EVENT on Windows, SIGINT on other platforms."""
206
- if sys .platform == "win32" :
207
- os .kill (pid , signal .CTRL_BREAK_EVENT )
208
- else :
209
- os .kill (pid , signal .SIGINT )
210
-
211
-
212
- def _open_subprocess (command : Sequence [str ]) -> "subprocess.Popen" :
213
- """Sets the correct flags to support graceful termination."""
214
- creationflags = 0
215
- if sys .platform == "win32" :
216
- creationflags = subprocess .CREATE_NEW_PROCESS_GROUP
217
-
218
- return subprocess .Popen (
219
- command ,
220
- creationflags = creationflags ,
221
- )
0 commit comments