Module: Hijack

Included in:
ApplicationController
Defined in:
lib/hijack.rb

Overview

This module allows us to hijack a request and send it to the client in the deferred job queue For cases where we are making remote calls like onebox or proxying files and so on this helps free up a unicorn worker while the remote IO is happening

Instance Method Summary collapse

Instance Method Details

#hijack(info: nil, &blk) ⇒ Object



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
# File 'lib/hijack.rb', line 9

def hijack(info: nil, &blk)
  controller_class = self.class

  if hijack = request.env["rack.hijack"]
    request.env["discourse.request_tracker.skip"] = true
    request_tracker = request.env["discourse.request_tracker"]

    # in the past unicorn would recycle env, this is not longer the case
    env = request.env

    # rack may clean up tempfiles unless we trick it and take control
    tempfiles = env[Rack::RACK_TEMPFILES]
    env[Rack::RACK_TEMPFILES] = nil
    request_copy = ActionDispatch::Request.new(env)

    transfer_timings = MethodProfiler.transfer

    scheduled = Concurrent::Promises.resolvable_event

    begin
      Scheduler::Defer.later(
        "hijack #{params["controller"]} #{params["action"]} #{info}",
        force: false,
        &scheduled.method(:resolve)
      )
    rescue WorkQueue::WorkQueueFull
      return render plain: "", status: 503
    end

    # duplicate headers so other middleware does not mess with it
    # on the way down the stack
    original_headers = response.headers.dup

    io = hijack.call

    scheduled.on_resolution! do
      MethodProfiler.start(transfer_timings)
      begin
        Thread.current[Logster::Logger::LOGSTER_ENV] = env
        # do this first to confirm we have a working connection
        # before doing any work
        io.write "HTTP/1.1 "

        # this trick avoids double render, also avoids any litter that the controller hooks
        # place on the response
        instance = controller_class.new
        response = ActionDispatch::Response.new
        instance.response = response

        instance.request = request_copy
        original_headers&.each { |k, v| instance.response.headers[k] = v }

        view_start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
        begin
          instance.instance_eval(&blk)
        rescue => e
          # TODO we need to reuse our exception handling in ApplicationController
          Discourse.warn_exception(
            e,
            message: "Failed to process hijacked response correctly",
            env: env,
          )
        end
        view_runtime = Process.clock_gettime(Process::CLOCK_MONOTONIC) - view_start

        instance.status = 500 unless instance.response_body || response.committed?

        response.commit!

        body = response.body

        headers = response.headers
        # add cors if needed
        if cors_origins = env[Discourse::Cors::ORIGINS_ENV]
          Discourse::Cors.apply_headers(cors_origins, env, headers)
        end

        headers["Content-Type"] ||= response.content_type || "text/plain"
        headers["Content-Length"] = body.bytesize
        headers["Connection"] = "close"

        headers["Discourse-Logged-Out"] = "1" if env[Auth::DefaultCurrentUserProvider::BAD_TOKEN]

        status_string = Rack::Utils::HTTP_STATUS_CODES[response.status.to_i] || "Unknown"
        io.write "#{response.status} #{status_string}\r\n"

        timings = MethodProfiler.stop
        if timings && duration = timings[:total_duration]
          headers["X-Runtime"] = "#{"%0.6f" % duration}"
        end

        headers.each { |name, val| io.write "#{name}: #{val}\r\n" }

        io.write "\r\n"
        io.write body
      rescue Errno::EPIPE, IOError
        # happens if client terminated before we responded, ignore
        io = nil
      ensure
        if Rails.configuration.try(:lograge).try(:enabled)
          if timings
            db_runtime = 0
            db_runtime = timings[:sql][:duration] if timings[:sql]

            subscriber = Lograge::LogSubscribers::ActionController.new
            payload =
              ActiveSupport::HashWithIndifferentAccess.new(
                controller: self.class.name,
                action: action_name,
                params: request.filtered_parameters,
                headers: request.headers,
                format: request.format.ref,
                method: request.request_method,
                path: request.fullpath,
                view_runtime: view_runtime * 1000.0,
                db_runtime: db_runtime * 1000.0,
                timings: timings,
                status: response.status,
              )

            event =
              ActiveSupport::Notifications::Event.new(
                "hijack",
                Time.now,
                Time.now + timings[:total_duration],
                "",
                payload,
              )
            subscriber.process_action(event)
          end
        end

        MethodProfiler.clear
        Thread.current[Logster::Logger::LOGSTER_ENV] = nil

        begin
          io.close if io
        rescue StandardError
          nil
        end

        if request_tracker
          status =
            begin
              response.status
            rescue StandardError
              500
            end
          request_tracker.log_request_info(env, [status, headers || {}, []], timings)
        end

        tempfiles&.each(&:close!)
      end
    end

    # not leaked out, we use 418 ... I am a teapot to denote that we are hijacked
    render plain: "", status: 418
  else
    blk.call
  end
end