Module: Tina4::PortTakeover
- Defined in:
- lib/tina4/port_takeover.rb
Overview
Identity-checked port takeover, shared by the CLI and the runtime paths.
tina4 serve reclaims a busy port so the edit-restart loop does not fail
with "address already in use". The convenience has a sharp edge: "whatever is
listening" is not always the old Tina4 server, and before this module BOTH
takeover paths (the CLI #kill_process_on_port and the runtime bind-failure
WebServer#free_port) SIGTERM'd whatever held the port, with NO check that the
victim was a Tina4 dev server -- a foreign holder (another dev server, a
database, a stray listener) was killed.
This is the ONE takeover implementation both paths call (TAKEOVER-DEC-02), so the runtime path can never again be a weaker twin of the CLI path. It adds:
- Identity (TAKEOVER-DEC-01): a Tina4 dev server writes a per-port PID file
(
data/.tina4-serve-<port>.pid) when it binds and removes it on clean exit. Takeover only signals a holder whose PID matches that file; a holder with no matching Tina4 PID file is REFUSED, never killed. - Dev gate + opt-out (TAKEOVER-DEC-03): takeover runs only in dev
(
TINA4_DEBUGtruthy) and only when not opted out (TINA4_NO_TAKEOVER/tina4 serve --no-kill). A production bind never kills a port holder. - The existing PID safety filter and container guard, unchanged, on top.
Refusing is always safe (the developer frees the port by hand); over-killing was the bug this fixes.
Defined Under Namespace
Classes: Result
Constant Summary collapse
- NOTHING =
"nothing"- KILLED =
"killed"- REFUSED_FOREIGN =
"refused_foreign"- REFUSED_OPTOUT =
"refused_optout"- REFUSED_PROD =
"refused_prod"- SKIPPED_CONTAINER =
"skipped_container"- REFUSALS =
[REFUSED_FOREIGN, REFUSED_OPTOUT, REFUSED_PROD].freeze
Class Method Summary collapse
-
.dev? ⇒ Boolean
Dev mode = TINA4_DEBUG truthy.
-
.in_container? ⇒ Boolean
True when this process is running inside a container.
-
.no_takeover_opted_out? ⇒ Boolean
True when takeover is disabled via TINA4_NO_TAKEOVER.
- .pidfile_path(port, base_dir = nil) ⇒ Object
-
.port_holders(port) ⇒ Object
Raw lsof/netstat PID tokens for whatever holds port.
-
.read_pidfile(port, base_dir = nil) ⇒ Object
The PID a Tina4 dev server recorded for port, or nil if none/garbage.
-
.remove_pidfile(port, base_dir = nil) ⇒ Object
Drop the PID file for port (clean shutdown, or after reclaiming it).
- .runtime_dir(base_dir = nil) ⇒ Object
-
.selectable_pids(lsof_output, me, my_group = nil) ⇒ Object
The PIDs from
lsof -tioutput that are safe to signal. -
.take_over_port(port, dev:, no_takeover:, base_dir: nil, grace: 0.5) ⇒ Object
Reclaim port ONLY from an identity-confirmed Tina4 dev server.
- .truthy?(value) ⇒ Boolean
-
.write_pidfile(port, base_dir = nil, pid = nil) ⇒ Object
Record THIS process as the Tina4 dev server on port (best-effort).
Class Method Details
.dev? ⇒ Boolean
Dev mode = TINA4_DEBUG truthy. Takeover runs only in dev.
55 56 57 |
# File 'lib/tina4/port_takeover.rb', line 55 def dev? truthy?(ENV["TINA4_DEBUG"]) end |
.in_container? ⇒ Boolean
True when this process is running inside a container. Reclaiming a port makes sense on a dev machine; inside a container the server IS the container, so there is no stale sibling to reclaim from.
67 68 69 70 71 72 73 74 |
# File 'lib/tina4/port_takeover.rb', line 67 def in_container? return true if File.exist?("/.dockerenv") || File.exist?("/run/.containerenv") blob = File.read("/proc/1/cgroup") blob.include?("docker") || blob.include?("containerd") || blob.include?("kubepods") rescue SystemCallError false end |
.no_takeover_opted_out? ⇒ Boolean
True when takeover is disabled via TINA4_NO_TAKEOVER.
60 61 62 |
# File 'lib/tina4/port_takeover.rb', line 60 def no_takeover_opted_out? truthy?(ENV["TINA4_NO_TAKEOVER"]) end |
.pidfile_path(port, base_dir = nil) ⇒ Object
102 103 104 |
# File 'lib/tina4/port_takeover.rb', line 102 def pidfile_path(port, base_dir = nil) File.join(runtime_dir(base_dir), ".tina4-serve-#{port}.pid") end |
.port_holders(port) ⇒ Object
Raw lsof/netstat PID tokens for whatever holds port.
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 |
# File 'lib/tina4/port_takeover.rb', line 132 def port_holders(port) if RUBY_PLATFORM =~ /mswin|mingw|cygwin/ tokens = [] `netstat -ano 2>&1`.each_line do |line| next unless line.include?(":#{port}") && (line.include?("LISTENING") || line.include?("ESTABLISHED")) candidate = line.strip.split(/\s+/).last tokens << candidate if candidate&.match?(/\A\d+\z/) end tokens else `lsof -ti :#{port} 2>/dev/null`.split end rescue StandardError [] end |
.read_pidfile(port, base_dir = nil) ⇒ Object
The PID a Tina4 dev server recorded for port, or nil if none/garbage.
117 118 119 120 121 122 |
# File 'lib/tina4/port_takeover.rb', line 117 def read_pidfile(port, base_dir = nil) token = File.read(pidfile_path(port, base_dir)).strip token.match?(/\A\d+\z/) ? token.to_i : nil rescue SystemCallError nil end |
.remove_pidfile(port, base_dir = nil) ⇒ Object
Drop the PID file for port (clean shutdown, or after reclaiming it).
125 126 127 128 129 |
# File 'lib/tina4/port_takeover.rb', line 125 def remove_pidfile(port, base_dir = nil) File.delete(pidfile_path(port, base_dir)) rescue SystemCallError nil end |
.runtime_dir(base_dir = nil) ⇒ Object
98 99 100 |
# File 'lib/tina4/port_takeover.rb', line 98 def runtime_dir(base_dir = nil) base_dir || File.join(Dir.pwd, "data") end |
.selectable_pids(lsof_output, me, my_group = nil) ⇒ Object
The PIDs from lsof -ti output that are safe to signal.
Pure so the safety rule can be tested directly. A non-numeric field becomes
0 under to_i, and signalling PID 0 hits EVERY process in the caller's own
process group -- the server kills itself. Accept only all-digit tokens;
never PID 0 (our group), PID 1 (init), ourselves, or our own process group.
This is the PID-SAFETY gate only; whether a survivor is a Tina4 server is
the SEPARATE identity check in #take_over_port.
84 85 86 87 88 89 90 91 92 93 94 95 96 |
# File 'lib/tina4/port_takeover.rb', line 84 def selectable_pids(lsof_output, me, my_group = nil) pids = [] lsof_output.split(/\s+/).each do |token| next unless token.match?(/\A\d+\z/) # never coerce junk into a PID pid = token.to_i next if pid <= 1 || pid == me # 0 = our group, 1 = init, me = suicide next if !my_group.nil? && pid == my_group pids << pid unless pids.include?(pid) end pids end |
.take_over_port(port, dev:, no_takeover:, base_dir: nil, grace: 0.5) ⇒ Object
Reclaim port ONLY from an identity-confirmed Tina4 dev server. The single
guarded path for both the CLI (tina4 serve) and the runtime bind-failure
fallback. dev/no_takeover are passed in so this stays pure and directly
testable; callers resolve them from #dev? / #no_takeover_opted_out?.
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 |
# File 'lib/tina4/port_takeover.rb', line 154 def take_over_port(port, dev:, no_takeover:, base_dir: nil, grace: 0.5) if no_takeover return Result.new(REFUSED_OPTOUT, port, [], "Port #{port} is in use and takeover is disabled " \ "(TINA4_NO_TAKEOVER/--no-kill) -- free it or choose another port.") end unless dev return Result.new(REFUSED_PROD, port, [], "Port #{port} is in use; takeover is disabled outside dev mode " \ "-- free it or choose another port.") end return Result.new(SKIPPED_CONTAINER, port, [], "") if in_container? tokens = port_holders(port) return Result.new(NOTHING, port, [], "") if tokens.empty? me = Process.pid my_group = begin Process.getpgrp rescue StandardError nil end holders = selectable_pids(tokens.join(" "), me, my_group) return Result.new(NOTHING, port, [], "") if holders.empty? recorded = read_pidfile(port, base_dir) tina4_holders = recorded.nil? ? [] : holders.select { |pid| pid == recorded } if tina4_holders.empty? return Result.new(REFUSED_FOREIGN, port, [], "Port #{port} is held by a non-Tina4 process " \ "-- free it or choose another port.") end killed = [] tina4_holders.each do |pid| Process.kill("TERM", pid) killed << pid rescue Errno::ESRCH, Errno::EPERM # already gone or no permission end return Result.new(NOTHING, port, [], "") if killed.empty? remove_pidfile(port, base_dir) sleep(grace) if grace.positive? Result.new(KILLED, port, killed, "Reclaimed port #{port} from Tina4 dev server (PID: #{killed.join(', ')}).") end |
.truthy?(value) ⇒ Boolean
50 51 52 |
# File 'lib/tina4/port_takeover.rb', line 50 def truthy?(value) %w[true 1 yes on].include?(value.to_s.strip.downcase) end |
.write_pidfile(port, base_dir = nil, pid = nil) ⇒ Object
Record THIS process as the Tina4 dev server on port (best-effort).
107 108 109 110 111 112 113 114 |
# File 'lib/tina4/port_takeover.rb', line 107 def write_pidfile(port, base_dir = nil, pid = nil) dir = runtime_dir(base_dir) require "fileutils" FileUtils.mkdir_p(dir) File.write(pidfile_path(port, base_dir), (pid || Process.pid).to_s) rescue SystemCallError nil # identity is a convenience; never let it break the server end |