diff --git a/bin/tep b/bin/tep index a66fcb1..1c34e08 100755 --- a/bin/tep +++ b/bin/tep @@ -898,6 +898,7 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1") out << <<~RB __port = #{config[:port] || 4567} __workers = #{config[:workers] || 1} + __sockpath = "" __quiet = false __scheduled = #{scheduled ? 'true' : 'false'} __i = 0 @@ -909,6 +910,18 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1") elsif __a == "-w" && __i + 1 < ARGV.length __workers = ARGV[__i + 1].to_i __i += 2 + elsif __a == "--socket" && __i + 1 < ARGV.length + # Serve over this unix-domain socket path instead of TCP (the + # argv deployment platforms like CARLOS spawn instances with). + __sockpath = ARGV[__i + 1] + __i += 2 + elsif __a == "--db" && __i + 1 < ARGV.length + # Platform-supplied DB path; tolerated so a platform can pass it + # unconditionally. Apps that want it read it from ARGV themselves. + __i += 2 + elsif __a == "serve" + # Tolerated no-op: platform unit templates default to ` serve`. + __i += 1 elsif __a == "-q" __quiet = true __i += 1 @@ -921,7 +934,11 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1") __i += 1 end end - Tep.run!(__port, __workers, __quiet, __scheduled) + if __sockpath.length > 0 + Tep.run_unix!(__sockpath, __quiet) + else + Tep.run!(__port, __workers, __quiet, __scheduled) + end RB out << "" diff --git a/tep.rb b/tep.rb index 92a8ae4..7fb112a 100644 --- a/tep.rb +++ b/tep.rb @@ -903,6 +903,11 @@ module Tep # accepts the call without the 4th arg only because it supports # default-value params; without this, the 3-arg call silently # miscompiled (matz/spinel arity-warning shape, tep#13). + # Unix-socket twin of run! -- see Server#run_unix. + def self.run_unix!(path, quiet) + Server.new(APP).run_unix(path, quiet) + end + def self.run!(port, workers, quiet, scheduled = false) if scheduled Server::Scheduled.new(APP).run(port, workers, quiet) diff --git a/tep/app.rb b/tep/app.rb index 7668be8..500da09 100644 --- a/tep/app.rb +++ b/tep/app.rb @@ -63,6 +63,13 @@ module Tep # Late-seeded for the same reason as openai_backend. attr_accessor :openai_events attr_accessor :asset_bodies, :asset_mimes, :asset_etags + # Serve every response with Connection: close (no keep-alive reuse). + # Set by Server#run_unix: that mode is a single blocking worker, and + # a reverse proxy's idle keep-alive connection would wedge it — the + # worker blocks reading the idle socket while every other connection + # queues behind it (observed live behind CARLOS's activator proxy: + # intermittent hangs + dropped POSTs until Go's 90s idle timeout). + attr_accessor :close_per_request attr_accessor :sched_fibers, :sched_wake_at, :sched_current attr_accessor :sched_io_fd, :sched_io_mode, :sched_io_ready # Tep::Job background-worker idempotency flag. App-level so a @@ -113,6 +120,7 @@ module Tep @asset_mimes = Tep.str_hash # by Tep::Assets._add lines # the bin/tep translator emits) @asset_etags = Tep.str_hash # path -> content-hash ETag (#152) + @close_per_request = false # FiberSlot array for the cooperative scheduler. Initialise # with a noop-bodied slot to pin the array element type, then # drop it. Each slot holds one Fiber + a timer entry in the diff --git a/tep/net.rb b/tep/net.rb index 45d22e9..7a20724 100644 --- a/tep/net.rb +++ b/tep/net.rb @@ -24,6 +24,9 @@ module Sock ffi_lib "crypto" ffi_func :sphttp_listen, [:int, :int], :int + # Unix-domain listener: bind+listen on a filesystem path (see + # sphttp.c). -1 on failure; accept/read/write reuse the same fd paths. + ffi_func :sphttp_listen_unix, [:str], :int ffi_func :sphttp_accept, [:int], :int # Non-blocking accept variant used by Tep::Server::Scheduled. # Listen fd must be in non-blocking mode (sphttp_set_nonblock). diff --git a/tep/server.rb b/tep/server.rb index 1bec928..8e81dea 100644 --- a/tep/server.rb +++ b/tep/server.rb @@ -96,6 +96,31 @@ module Tep end end + + # Serve over a unix-domain socket path: the shape deployment + # platforms hand an instance app (`--socket `). One process, + # one listener -- prefork/SO_REUSEPORT does not apply to AF_UNIX, + # and socket-path apps are typically hibernated single instances. + def run_unix(path, quiet) + if !quiet + puts "[tep " + VERSION + "] listening on unix:" + path + end + # One blocking worker serves this socket: an idle kept-alive proxy + # connection would starve every other connection. Close per request; + # re-dialing a unix socket is effectively free. + Tep::APP.close_per_request = true + Sock.sphttp_install_term_handlers + sfd = Sock.sphttp_listen_unix(path) + if sfd < 0 + puts "tep: bind failed on " + path + exit(1) + end + worker_loop(sfd) + if Sock.sphttp_shutdown_requested != 0 + Tep.on_shutdown + end + end + def worker_loop(sfd) loop do client = Sock.sphttp_accept(sfd) @@ -151,7 +176,7 @@ module Tep res = Response.new @app.dispatch(req, res) - keep_alive = req.keep_alive? && !res.halted_close? + keep_alive = req.keep_alive? && !res.halted_close? && !Tep::APP.close_per_request write_response(client, req, res, keep_alive) keep_going = keep_alive end @@ -240,7 +265,12 @@ module Tep if !res.headers.key?("Content-Type") && res.status != 204 && res.status != 304 res.headers["Content-Type"] = "text/html; charset=utf-8" end - res.headers["Content-Length"] = res.body.length.to_s + # bytesize, not length: Content-Length is an OCTET count, and a + # body with multi-byte UTF-8 (an embedded asset, upstream API + # text) otherwise declares fewer bytes than are written -- the + # client truncates the tail (seen live: a bundled .js losing its + # closing braces, killing the whole script). + res.headers["Content-Length"] = res.body.bytesize.to_s if keep_alive res.headers["Connection"] = "keep-alive" else diff --git a/tep/sphttp.c b/tep/sphttp.c index 6b7863e..7af1b04 100644 --- a/tep/sphttp.c +++ b/tep/sphttp.c @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -126,6 +127,26 @@ int sphttp_sleep_ms(int ms) { * term-flag-aware pre-check + EINTR handling as the old body; the * shutdown flag is now sp_net's (set via sp_net_install_term_handlers, * which sphttp_install_term_handlers delegates to). */ + +/* Unix-domain listener (deployment platforms that hand an app a socket + * path -- e.g. CARLOS spawns instances as ` --socket ` and + * proxies HTTP over it). Unlinks a stale path, binds, listens. Single + * fd, no SO_REUSEPORT (kernel load-balancing is a TCP concern; unix + * instance apps run one process). Returns the listening fd or -1. */ +int sphttp_listen_unix(const char *path) { + struct sockaddr_un addr; + if (strlen(path) >= sizeof(addr.sun_path)) return -1; + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) return -1; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + unlink(path); + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { close(fd); return -1; } + if (listen(fd, 128) < 0) { close(fd); unlink(path); return -1; } + return fd; +} + int sphttp_listen(int port, int reuseport) { return sp_net_listen(port, reuseport); } int sphttp_accept(int sfd) { return sp_net_accept(sfd); } int sphttp_accept_nb(int sfd) { return sp_net_accept_nb(sfd); }