Source file channel.ml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
open! Core
open Async

module Command_update = struct
  type t =
    | Status of Protocol.Status_update.Update.t
    | Output of string list
    | Session of Tmux.Session_id.t
end

module Entry = struct
  type t =
    | Command of
        { entry : Protocol.Register_commands.Entry.command
        ; updates : Command_update.t Pipe.Reader.t
        }
    | Group of
        { entry : Protocol.Register_commands.Entry.group
        ; updates : Protocol.Group_status.Update.t Pipe.Reader.t
        }
end

(** Per-command state tracked by the channel. Created when a command is registered and
    persists for the lifetime of the channel. *)
module Command_state = struct
  type t =
    { connection : Rpc.Connection.t
    ; writer : Command_update.t Pipe.Writer.t
    ; mutable process_output_finished : unit Deferred.t option
    (** Resolves when the current run's output FIFO is fully drained. Reset on each new
        process start. *)
    }
end

(** Per-group state tracked by the channel. *)
module Group_state = struct
  type t = { writer : Protocol.Group_status.Update.t Pipe.Writer.t }
end

type t =
  { server : (Socket.Address.Unix.t, string) Tcp.Server.t Ivar.t
  ; socket_path : string
  ; entries_ready : (prefix:Command_id.t * entries:Entry.t list) Pipe.Reader.t
  ; entries_ready_write : (prefix:Command_id.t * entries:Entry.t list) Pipe.Writer.t
  ; logs : Protocol.Client_log.Msg.t Pipe.Reader.t
  ; logs_write : Protocol.Client_log.Msg.t Pipe.Writer.t
  ; commands : Command_state.t Command_id.Table.t
  ; groups : Group_state.t Command_id.Table.t
  }

let socket_path t = t.socket_path
let entries_ready t = t.entries_ready
let logs t = t.logs

let debug t message =
  Pipe.write_without_pushback_if_open
    t.logs_write
    { Protocol.Client_log.Msg.level = Debug; id = None; message }
;;

let proctopus_runtime_dir () =
  let xdg_runtime_dir =
    match Sys.getenv "XDG_RUNTIME_DIR" with
    | Some dir -> dir
    | None -> [%string "/run/user/%{Core_unix.getuid ()#Int}"]
  in
  let dir = xdg_runtime_dir ^/ "proctopus" in
  Core_unix.mkdir_p dir;
  dir
;;

let create_socket_path () =
  let pid = Core_unix.getpid () |> Pid.to_int in
  proctopus_runtime_dir () ^/ sprintf "%d.sock" pid
;;

let register_command
  t
  ~connection
  ~prefix
  (cmd : Protocol.Register_commands.Entry.command)
  =
  let full_id = Command_id.concat prefix cmd.id in
  let reader, writer = Pipe.create () in
  Hashtbl.set
    t.commands
    ~key:full_id
    ~data:{ Command_state.connection; writer; process_output_finished = None };
  Entry.Command { entry = cmd; updates = reader }
;;

let register_group t ~prefix (grp : Protocol.Register_commands.Entry.group) =
  let full_id = Command_id.concat prefix grp.id in
  let reader, writer = Pipe.create () in
  Hashtbl.set t.groups ~key:full_id ~data:{ Group_state.writer };
  Entry.Group { entry = grp; updates = reader }
;;

(* Create a FIFO in the proctopus runtime directory and return its path *)
let create_output_fifo () : Filename.t =
  let fifo =
    proctopus_runtime_dir ()
    ^/ [%string
         "output-%{Pid.to_int (Core_unix.getpid ())#Int}-%{Random.int 1000000#Int}.fifo"]
  in
  Core_unix.mkfifo fifo ~perm:0o600;
  fifo
;;

(* Dispatch start_process to the client that owns the given id. The server creates a FIFO
   for output streaming; the client writes to it and the server reads from it. All updates
   (started, output, stats, exit) arrive through the per-command pipe. *)
let dispatch_start_process t ~id =
  match Hashtbl.find t.commands id with
  | None ->
    return (Or_error.error_s [%sexp "No command registered for", (id : Command_id.t)])
  | Some command ->
    let output_fifo = create_output_fifo () in
    (* Open the FIFO with O_NONBLOCK so it returns immediately without consuming a
       thread-pool thread. This avoids exhausting the Async thread pool when many
       processes start concurrently (each blocking [Reader.open_file] would otherwise hold
       a thread until the write side opens). *)
    let reader =
      let fd =
        let raw_fd = Core_unix.openfile output_fifo ~mode:[ O_RDONLY; O_NONBLOCK ] in
        Fd.create Fifo raw_fd (Info.of_string output_fifo)
      in
      Reader.create fd
    in
    let query : Protocol.Start_process.Query.t = { id; output_fifo } in
    let%map.Deferred result =
      match%bind
        Protocol.Start_process.dispatch command.connection query >>| Or_error.join
      with
      | Error err ->
        don't_wait_for (Reader.close reader);
        let error_text = [%string "Failed to start: %{Error.to_string_hum err}"] in
        let lines = String.split_lines error_text in
        Pipe.write_without_pushback_if_open command.writer (Command_update.Output lines);
        return (Error err)
      | Ok () ->
        let output_finished =
          Pipe.transfer' (Reader.lines reader) command.writer ~f:(fun lines ->
            return (Queue.singleton (Command_update.Output (Queue.to_list lines))))
        in
        command.process_output_finished <- Some output_finished;
        return (Ok ())
    in
    (* Once we received a result from the dispatch, we can clean up the temporary path. *)
    Core_unix.unlink output_fifo;
    result
;;

(* Dispatch signal_process to the client that owns the given id *)
let dispatch_signal_process t ~id ~signal =
  match Hashtbl.find t.commands id with
  | None ->
    return (Or_error.error_s [%sexp "No command registered for", (id : Command_id.t)])
  | Some command ->
    let query : Protocol.Signal_process.Query.t = { id; signal } in
    (match%bind Protocol.Signal_process.dispatch command.connection query with
     | Error err ->
       return (Or_error.error_s [%sexp "RPC dispatch failed", (err : Error.t)])
     | Ok response -> return response)
;;

let close t =
  let%bind () =
    match%bind Sys.file_exists t.socket_path with
    | `Yes -> Unix.unlink t.socket_path
    | `No | `Unknown -> return ()
  and () =
    let%bind server = Ivar.read t.server in
    Tcp.Server.close server ~close_existing_connections:true
  in
  return ()
;;

let on_register_commands t ~connection (query : Protocol.Register_commands.Query.t) =
  let { Protocol.Register_commands.Query.entries; prefix } = query in
  let channel_entries =
    List.map entries ~f:(fun entry ->
      match entry with
      | Protocol.Register_commands.Entry.Command cmd ->
        register_command t ~connection ~prefix cmd
      | Protocol.Register_commands.Entry.Group grp -> register_group t ~prefix grp)
  in
  Pipe.write_without_pushback_if_open
    t.entries_ready_write
    (~prefix, ~entries:channel_entries);
  return (Ok ())
;;

let on_status_update t (msg : Protocol.Status_update.Msg.t) =
  match Hashtbl.find t.commands msg.id with
  | Some command ->
    (match msg.update with
     | Exited _ ->
       (* Wait for the output FIFO to drain before surfacing the exit event so that
          trailing output is not reordered with respect to the separator. Because all
          updates for a given command go through this single pipe, ordering is guaranteed. *)
       don't_wait_for
         (let%bind () =
            match command.process_output_finished with
            | Some output_finished -> output_finished
            | None -> return ()
          in
          Pipe.write_if_open command.writer (Command_update.Status msg.update))
     | Started _ | Stats _ ->
       Pipe.write_without_pushback_if_open
         command.writer
         (Command_update.Status msg.update))
  | None ->
    debug
      t
      [%string
        "Received status update for unknown command %{Command_id.to_string msg.id}"]
;;

let on_client_log t (msg : Protocol.Client_log.Msg.t) =
  Pipe.write_without_pushback_if_open t.logs_write msg
;;

let on_group_status t (msg : Protocol.Group_status.Msg.t) =
  match Hashtbl.find t.groups msg.id with
  | None ->
    debug
      t
      [%string "Received group status for unknown group %{Command_id.to_string msg.id}"]
  | Some group -> Pipe.write_without_pushback_if_open group.writer msg.update
;;

let on_interactive_session t (msg : Protocol.Interactive_session.Msg.t) =
  match Hashtbl.find t.commands msg.id with
  | Some command ->
    let session_id = Tmux.Session_id.t_of_sexp (Sexp.of_string msg.session_id_sexp) in
    Pipe.write_without_pushback_if_open command.writer (Command_update.Session session_id)
  | None ->
    debug
      t
      [%string
        "Received interactive session for unknown command %{Command_id.to_string msg.id}"]
;;

let start () =
  let socket_path = create_socket_path () in
  let entries_ready, entries_ready_write = Pipe.create () in
  let logs, logs_write = Pipe.create () in
  let t =
    { server = Ivar.create ()
    ; socket_path
    ; entries_ready
    ; entries_ready_write
    ; logs
    ; logs_write
    ; commands = Command_id.Table.create ()
    ; groups = Command_id.Table.create ()
    }
  in
  let%bind () =
    match%bind Sys.file_exists socket_path with
    | `Yes -> Unix.unlink socket_path
    | `No | `Unknown -> return ()
  in
  let implementations =
    Rpc.Implementations.create_exn
      ~implementations:
        (Protocol.Register_commands.implement (fun connection query ->
           on_register_commands t ~connection query)
         @ Protocol.Status_update.implement (fun _connection msg ->
           on_status_update t msg)
         @ Protocol.Client_log.implement (fun _connection msg -> on_client_log t msg)
         @ Protocol.Group_status.implement (fun _connection msg -> on_group_status t msg)
         @ Protocol.Interactive_session.implement (fun _connection msg ->
           on_interactive_session t msg))
      ~on_unknown_rpc:`Close_connection
      ~on_exception:Log_on_background_exn
  in
  let%bind server =
    Tcp.Server.create
      ~on_handler_error:`Raise
      (Tcp.Where_to_listen.of_file socket_path)
      (fun _addr reader writer ->
         Rpc.Connection.server_with_close
           reader
           writer
           ~implementations
           ~connection_state:(fun connection -> connection)
           ~on_handshake_error:`Raise)
  in
  Ivar.fill_exn t.server server;
  Shutdown.at_shutdown (fun () -> close t);
  return t
;;