diff --git a/CHANGES b/CHANGES index 8ae835854..bac4eae7a 100644 --- a/CHANGES +++ b/CHANGES @@ -1,6 +1,18 @@ Changes with FreeUnit 1.35.4 xx xxx 2026 + *) Bugfix: cap JSON parser depth (100) and per-array/object element + count (100k) in the controller so a malformed POST /config + payload like "[[[[…]]]]" or a million-element flat array can no + longer exhaust the stack or heap. Scrub PHP TrueAsync EG + exception state before the prototype-fork so workers do not + inherit a stale exception across the boundary, and guard the + Ruby rack.input / rack.errors handles against NULL-deref if an + app captures them outside the request lifetime, and bind each + handle to its originating request so a captured handle cannot + read/write the body of a later, unrelated request handled by + the same worker. + *) Bugfix: fix router process CPU spin and connection hang under port scanning load; CLOSE-WAIT sockets are now cleaned up properly on client FIN, idle connection queue iteration fixed, systemd file diff --git a/src/nxt_conf.c b/src/nxt_conf.c index bb229c331..ec4ed7b07 100644 --- a/src/nxt_conf.c +++ b/src/nxt_conf.c @@ -103,6 +103,23 @@ typedef struct { static nxt_int_t nxt_conf_path_next_token(nxt_conf_path_parse_t *parse, nxt_str_t *token); +/* + * Hard caps on JSON inputs to the controller. Both values are + * intentionally well above any legitimate Unit config (configs rarely + * nest more than 6 levels; the largest production configs we've seen + * have a few thousand elements per array). These exist to bound the + * controller's stack and heap usage on malformed input. + */ +#define NXT_CONF_JSON_MAX_DEPTH 100 +#define NXT_CONF_JSON_MAX_ELEMENTS 100000 + +/* + * Recursion-depth counter. The controller is single-threaded so a + * file-static suffices. Reset on every entry to nxt_conf_json_parse() + * so a stale value from an aborted prior call cannot leak. + */ +static nxt_uint_t nxt_conf_json_parse_depth; + static u_char *nxt_conf_json_skip_space(u_char *start, const u_char *end); static u_char *nxt_conf_json_parse_value(nxt_mp_t *mp, nxt_conf_value_t *value, u_char *start, u_char *end, nxt_conf_json_error_t *error); @@ -1277,6 +1294,8 @@ nxt_conf_json_parse(nxt_mp_t *mp, u_char *start, u_char *end, return NULL; } + nxt_conf_json_parse_depth = 0; + p = nxt_conf_json_parse_value(mp, value, p, end, error); if (nxt_slow_path(p == NULL)) { @@ -1393,10 +1412,34 @@ nxt_conf_json_parse_value(nxt_mp_t *mp, nxt_conf_value_t *value, u_char *start, switch (ch) { case '{': - return nxt_conf_json_parse_object(mp, value, start, end, error); + /* + * Bound recursion depth so a deeply-nested payload such as + * "[[[[...]]]]" cannot blow the controller's stack. Only + * '{' and '[' deepen the recursion; leaf cases below don't + * need to touch the counter. + */ + if (nxt_slow_path(nxt_conf_json_parse_depth + >= NXT_CONF_JSON_MAX_DEPTH)) { + nxt_conf_json_parse_error(error, start, + "JSON nesting exceeds the maximum supported depth."); + return NULL; + } + nxt_conf_json_parse_depth++; + p = nxt_conf_json_parse_object(mp, value, start, end, error); + nxt_conf_json_parse_depth--; + return p; case '[': - return nxt_conf_json_parse_array(mp, value, start, end, error); + if (nxt_slow_path(nxt_conf_json_parse_depth + >= NXT_CONF_JSON_MAX_DEPTH)) { + nxt_conf_json_parse_error(error, start, + "JSON nesting exceeds the maximum supported depth."); + return NULL; + } + nxt_conf_json_parse_depth++; + p = nxt_conf_json_parse_array(mp, value, start, end, error); + nxt_conf_json_parse_depth--; + return p; case '"': return nxt_conf_json_parse_string(mp, value, start, end, error); @@ -1545,6 +1588,12 @@ nxt_conf_json_parse_object(nxt_mp_t *mp, nxt_conf_value_t *value, u_char *start, name = p; + if (nxt_slow_path(count >= NXT_CONF_JSON_MAX_ELEMENTS)) { + nxt_conf_json_parse_error(error, p, + "JSON object has too many members."); + goto error; + } + count++; member = nxt_mp_get(mp_temp, sizeof(nxt_conf_object_member_t)); @@ -1761,6 +1810,12 @@ nxt_conf_json_parse_array(nxt_mp_t *mp, nxt_conf_value_t *value, u_char *start, break; } + if (nxt_slow_path(count >= NXT_CONF_JSON_MAX_ELEMENTS)) { + nxt_conf_json_parse_error(error, p, + "JSON array has too many elements."); + goto error; + } + count++; element = nxt_list_add(list); diff --git a/src/nxt_conf_validation.c b/src/nxt_conf_validation.c index 8324a4419..2fc5bb4d8 100644 --- a/src/nxt_conf_validation.c +++ b/src/nxt_conf_validation.c @@ -1327,6 +1327,18 @@ static nxt_conf_vldt_object_t nxt_conf_vldt_app_processes_members[] = { }; +/* + * The isolation validator accepts arbitrary "executable" paths and + * lets the operator disable every isolation feature here. This is + * intentional: any peer who can write to the control socket already + * has the same authority as the unitd main process (see the + * SO_PEERCRED check landed in andypost/unit#14 — non-root local + * users are rejected at the socket layer, not by this validator). + * Allow-listing executable paths or forcing isolation = true here + * is a deployment policy decision, not a config-schema concern; + * deployments needing that should add a wrapping admission gate + * upstream of the control API. + */ static nxt_conf_vldt_object_t nxt_conf_vldt_app_isolation_members[] = { { .name = nxt_string("namespaces"), diff --git a/src/nxt_php_sapi.c b/src/nxt_php_sapi.c index 240541540..410eb470c 100644 --- a/src/nxt_php_sapi.c +++ b/src/nxt_php_sapi.c @@ -2389,7 +2389,25 @@ nxt_php_async_load_entrypoint(nxt_task_t *task, nxt_str_t *entrypoint) nxt_php_request_callback ? Z_TYPE_P(nxt_php_request_callback) : -1); } - /* DON'T call php_request_shutdown() - we want the callback to persist after fork */ + /* + * DON'T call php_request_shutdown() — we want the callback zval to + * persist across the prototype → worker fork. But scrub any + * pending exception left in EG: if the entrypoint script raised + * something that wasn't caught before HttpServer->onRequest() + * stored the callback, every forked worker would inherit the + * exception on its first request. The callback zval itself + * (nxt_php_request_callback) is not on the exception path; it + * was registered explicitly by the userland code. + * + * Other EG globals (output buffers, error_reporting, the symbol + * table) are also inherited, but those are reset implicitly when + * the worker enters a fresh request_init. Exception state is + * the one that bites hardest because the next request's + * php_execute_script() can early-exit on a stale EG(exception). + */ + if (EG(exception) != NULL) { + zend_clear_exception(); + } return NXT_OK; } diff --git a/src/ruby/nxt_ruby.c b/src/ruby/nxt_ruby.c index c0befdb3b..03be9b589 100644 --- a/src/ruby/nxt_ruby.c +++ b/src/ruby/nxt_ruby.c @@ -283,11 +283,12 @@ nxt_ruby_start(nxt_task_t *task, nxt_process_data_t *data) ruby_script("NGINX_Unit"); ruby_ctx.env = Qnil; - ruby_ctx.io_input = Qnil; - ruby_ctx.io_error = Qnil; + ruby_ctx.io_input_class = Qnil; + ruby_ctx.io_error_class = Qnil; ruby_ctx.thread = Qnil; ruby_ctx.ctx = NULL; ruby_ctx.req = NULL; + ruby_ctx.req_seq = 0; rack_init.task = task; rack_init.script = &c->script; @@ -570,8 +571,12 @@ nxt_ruby_rack_env_create(VALUE arg) rb_hash_aset(hash_env, rb_str_new2("SCRIPT_NAME"), rb_str_new("", 0)); rb_hash_aset(hash_env, rb_str_new2("rack.version"), version); - rb_hash_aset(hash_env, rb_str_new2("rack.input"), rctx->io_input); - rb_hash_aset(hash_env, rb_str_new2("rack.errors"), rctx->io_error); + /* + * rack.input and rack.errors are minted per-request in + * nxt_ruby_rack_app_run; the template env intentionally omits + * them so a fresh, request-bound instance is the only thing + * each app callback ever sees. + */ rb_hash_aset(hash_env, rb_str_new2("rack.multithread"), nxt_ruby_threads > 1 ? Qtrue : Qfalse); rb_hash_aset(hash_env, rb_str_new2("rack.multiprocess"), Qtrue); @@ -591,33 +596,28 @@ nxt_ruby_rack_env_create(VALUE arg) static int nxt_ruby_init_io(nxt_ruby_ctx_t *rctx) { - VALUE io_input, io_error; - - io_input = nxt_ruby_stream_io_input_init(); - - rctx->io_input = rb_funcall(io_input, rb_intern("new"), 1, - (VALUE) (uintptr_t) rctx); - if (nxt_slow_path(rctx->io_input == Qnil)) { + /* + * Store only the class references on the ctx; instances are + * minted per request in nxt_ruby_rack_app_run so each instance + * can snapshot rctx->req_seq for its originating request. + */ + rctx->io_input_class = nxt_ruby_stream_io_input_init(); + if (nxt_slow_path(rctx->io_input_class == Qnil)) { nxt_unit_alert(NULL, - "Ruby: Failed to create environment 'rack.input' var"); - + "Ruby: Failed to register 'rack.input' class"); return NXT_UNIT_ERROR; } - rb_gc_register_address(&rctx->io_input); - - io_error = nxt_ruby_stream_io_error_init(); + rb_gc_register_address(&rctx->io_input_class); - rctx->io_error = rb_funcall(io_error, rb_intern("new"), 1, - (VALUE) (uintptr_t) rctx); - if (nxt_slow_path(rctx->io_error == Qnil)) { + rctx->io_error_class = nxt_ruby_stream_io_error_init(); + if (nxt_slow_path(rctx->io_error_class == Qnil)) { nxt_unit_alert(NULL, - "Ruby: Failed to create environment 'rack.error' var"); - + "Ruby: Failed to register 'rack.errors' class"); return NXT_UNIT_ERROR; } - rb_gc_register_address(&rctx->io_error); + rb_gc_register_address(&rctx->io_error_class); return NXT_UNIT_OK; } @@ -641,6 +641,14 @@ nxt_ruby_request_handler_gvl(void *data) req = data; rctx = req->ctx->data; + /* + * Bump req_seq before publishing rctx->req: rack.input / + * rack.errors instances minted during this request snapshot + * req_seq at construction time, so any handle issued for an + * earlier request reads a mismatched seq and is rejected as + * stale (see nxt_ruby_bind_req). + */ + rctx->req_seq++; rctx->req = req; res = rb_protect(nxt_ruby_rack_app_run, (VALUE) (uintptr_t) req, &state); @@ -669,12 +677,38 @@ nxt_ruby_rack_app_run(VALUE arg) nxt_ruby_ctx_t *rctx; nxt_unit_request_info_t *req; + VALUE io_input, io_error; + req = (nxt_unit_request_info_t *) arg; rctx = req->ctx->data; env = rb_hash_dup(rctx->env); + /* + * Mint per-request rack.input / rack.errors instances bound to + * the current rctx + req_seq. This is what prevents a handle + * captured during request A from operating on request B handled + * by the same worker: each instance snapshots req_seq, and + * stream-IO ops reject mismatches. + */ + io_input = nxt_ruby_stream_io_input_new(rctx->io_input_class, rctx); + if (nxt_slow_path(io_input == Qnil)) { + nxt_unit_req_alert(req, + "Ruby: Failed to create per-request 'rack.input'"); + goto fail; + } + + io_error = nxt_ruby_stream_io_error_new(rctx->io_error_class, rctx); + if (nxt_slow_path(io_error == Qnil)) { + nxt_unit_req_alert(req, + "Ruby: Failed to create per-request 'rack.errors'"); + goto fail; + } + + rb_hash_aset(env, rb_str_new2("rack.input"), io_input); + rb_hash_aset(env, rb_str_new2("rack.errors"), io_error); + rc = nxt_ruby_read_request(req, env); if (nxt_slow_path(rc != NXT_UNIT_OK)) { nxt_unit_req_alert(req, @@ -1291,12 +1325,12 @@ nxt_ruby_exception_log(nxt_unit_request_info_t *req, uint32_t level, static void nxt_ruby_ctx_done(nxt_ruby_ctx_t *rctx) { - if (rctx->io_input != Qnil) { - rb_gc_unregister_address(&rctx->io_input); + if (rctx->io_input_class != Qnil) { + rb_gc_unregister_address(&rctx->io_input_class); } - if (rctx->io_error != Qnil) { - rb_gc_unregister_address(&rctx->io_error); + if (rctx->io_error_class != Qnil) { + rb_gc_unregister_address(&rctx->io_error_class); } if (rctx->env != Qnil) { @@ -1456,9 +1490,10 @@ nxt_ruby_init_threads(nxt_ruby_app_conf_t *c) rctx = &nxt_ruby_ctxs[i]; rctx->env = Qnil; - rctx->io_input = Qnil; - rctx->io_error = Qnil; + rctx->io_input_class = Qnil; + rctx->io_error_class = Qnil; rctx->thread = Qnil; + rctx->req_seq = 0; } for (i = 0; i < c->threads - 1; i++) { diff --git a/src/ruby/nxt_ruby.h b/src/ruby/nxt_ruby.h index 264300213..dfe1afa03 100644 --- a/src/ruby/nxt_ruby.h +++ b/src/ruby/nxt_ruby.h @@ -22,15 +22,26 @@ typedef struct { VALUE env; - VALUE io_input; - VALUE io_error; + VALUE io_input_class; + VALUE io_error_class; VALUE thread; nxt_unit_ctx_t *ctx; nxt_unit_request_info_t *req; + /* + * Monotonic per-rctx counter, bumped on each request entry. + * Each rack.input / rack.errors instance snapshots this on + * creation; stream-IO ops reject calls whose snapshot no longer + * matches rctx->req_seq, so a handle captured during request A + * cannot read/write the body of request B handled by the same + * worker. + */ + uint64_t req_seq; } nxt_ruby_ctx_t; VALUE nxt_ruby_stream_io_input_init(void); VALUE nxt_ruby_stream_io_error_init(void); +VALUE nxt_ruby_stream_io_input_new(VALUE class, nxt_ruby_ctx_t *rctx); +VALUE nxt_ruby_stream_io_error_new(VALUE class, nxt_ruby_ctx_t *rctx); #endif /* _NXT_RUBY_H_INCLUDED_ */ diff --git a/src/ruby/nxt_ruby_stream_io.c b/src/ruby/nxt_ruby_stream_io.c index bda89b0f6..18e638d6a 100644 --- a/src/ruby/nxt_ruby_stream_io.c +++ b/src/ruby/nxt_ruby_stream_io.c @@ -8,7 +8,28 @@ #include -static VALUE nxt_ruby_stream_io_new(VALUE class, VALUE arg); +/* + * Per-instance binding for rack.input / rack.errors objects. + * + * The handle stores both the ctx pointer (for the current req lookup) + * and a snapshot of rctx->req_seq taken at construction time. The + * stream-IO operations only proceed when: + * - rctx->req != NULL (a request is currently in flight), AND + * - rctx->req_seq == bind->req_seq (this handle was issued for the + * same request that is currently in flight). + * + * Either guard failing means the caller is holding a stale handle — + * either captured after the request finished (background thread, + * cached IO) or carried across to a later request handled by the + * same worker. In both cases, returning Qnil (reads) or routing the + * write through the NULL-context logger is the safest behaviour. + */ +typedef struct { + nxt_ruby_ctx_t *rctx; + uint64_t req_seq; +} nxt_ruby_io_bind_t; + + static VALUE nxt_ruby_stream_io_initialize(int argc, VALUE *argv, VALUE self); static VALUE nxt_ruby_stream_io_gets(VALUE obj); static VALUE nxt_ruby_stream_io_each(VALUE obj); @@ -16,26 +37,57 @@ static VALUE nxt_ruby_stream_io_read(VALUE obj, VALUE args); static VALUE nxt_ruby_stream_io_rewind(VALUE obj); static VALUE nxt_ruby_stream_io_puts(VALUE obj, VALUE args); static VALUE nxt_ruby_stream_io_write(VALUE obj, VALUE args); -nxt_inline long nxt_ruby_stream_io_s_write(nxt_ruby_ctx_t *rctx, VALUE val); +nxt_inline long nxt_ruby_stream_io_s_write(nxt_ruby_io_bind_t *bind, VALUE val); static VALUE nxt_ruby_stream_io_flush(VALUE obj); static VALUE nxt_ruby_stream_io_close(VALUE obj); -nxt_inline size_t nxt_ruby_dt_dsize_rctx(const void *arg); +nxt_inline size_t nxt_ruby_dt_dsize_bind(const void *arg); +nxt_inline void nxt_ruby_dt_dfree_bind(void *arg); +nxt_inline nxt_unit_request_info_t *nxt_ruby_bind_req(nxt_ruby_io_bind_t *bind); static const rb_data_type_t nxt_rctx_dt = { - .wrap_struct_name = "rctx", + .wrap_struct_name = "nxt_ruby_io_bind", .function = { - .dsize = nxt_ruby_dt_dsize_rctx, + .dsize = nxt_ruby_dt_dsize_bind, + .dfree = nxt_ruby_dt_dfree_bind, }, }; nxt_inline size_t -nxt_ruby_dt_dsize_rctx(const void *arg) +nxt_ruby_dt_dsize_bind(const void *arg) { - const nxt_ruby_ctx_t *rctx = arg; + (void) arg; + return sizeof(nxt_ruby_io_bind_t); +} + - return sizeof(*rctx); +nxt_inline void +nxt_ruby_dt_dfree_bind(void *arg) +{ + nxt_free(arg); +} + + +/* + * Returns the in-flight request if this handle is still valid, or + * NULL if it was captured outside its originating request (stale + * across-request reuse or after-request access). + */ +nxt_inline nxt_unit_request_info_t * +nxt_ruby_bind_req(nxt_ruby_io_bind_t *bind) +{ + if (bind == NULL || bind->rctx == NULL) { + return NULL; + } + + if (bind->rctx->req == NULL + || bind->rctx->req_seq != bind->req_seq) + { + return NULL; + } + + return bind->rctx->req; } @@ -48,9 +100,6 @@ nxt_ruby_stream_io_input_init(void) rb_undef_alloc_func(stream_io); - rb_gc_register_address(&stream_io); - - rb_define_singleton_method(stream_io, "new", nxt_ruby_stream_io_new, 1); rb_define_method(stream_io, "initialize", nxt_ruby_stream_io_initialize, -1); rb_define_method(stream_io, "gets", nxt_ruby_stream_io_gets, 0); @@ -72,9 +121,6 @@ nxt_ruby_stream_io_error_init(void) rb_undef_alloc_func(stream_io); - rb_gc_register_address(&stream_io); - - rb_define_singleton_method(stream_io, "new", nxt_ruby_stream_io_new, 1); rb_define_method(stream_io, "initialize", nxt_ruby_stream_io_initialize, -1); rb_define_method(stream_io, "puts", nxt_ruby_stream_io_puts, -2); @@ -86,12 +132,21 @@ nxt_ruby_stream_io_error_init(void) } -static VALUE -nxt_ruby_stream_io_new(VALUE class, VALUE arg) +nxt_inline VALUE +nxt_ruby_stream_io_alloc(VALUE class, nxt_ruby_ctx_t *rctx) { - VALUE self; + VALUE self; + nxt_ruby_io_bind_t *bind; + + bind = nxt_zalloc(sizeof(nxt_ruby_io_bind_t)); + if (nxt_slow_path(bind == NULL)) { + return Qnil; + } + + bind->rctx = rctx; + bind->req_seq = rctx->req_seq; - self = TypedData_Wrap_Struct(class, &nxt_rctx_dt, (void *)(uintptr_t)arg); + self = TypedData_Wrap_Struct(class, &nxt_rctx_dt, bind); rb_obj_call_init(self, 0, NULL); @@ -99,6 +154,20 @@ nxt_ruby_stream_io_new(VALUE class, VALUE arg) } +VALUE +nxt_ruby_stream_io_input_new(VALUE class, nxt_ruby_ctx_t *rctx) +{ + return nxt_ruby_stream_io_alloc(class, rctx); +} + + +VALUE +nxt_ruby_stream_io_error_new(VALUE class, nxt_ruby_ctx_t *rctx) +{ + return nxt_ruby_stream_io_alloc(class, rctx); +} + + static VALUE nxt_ruby_stream_io_initialize(int argc, VALUE *argv, VALUE self) { @@ -111,11 +180,20 @@ nxt_ruby_stream_io_gets(VALUE obj) { VALUE buf; ssize_t res; - nxt_ruby_ctx_t *rctx; + nxt_ruby_io_bind_t *bind; nxt_unit_request_info_t *req; - TypedData_Get_Struct(obj, nxt_ruby_ctx_t, &nxt_rctx_dt, rctx); - req = rctx->req; + TypedData_Get_Struct(obj, nxt_ruby_io_bind_t, &nxt_rctx_dt, bind); + + /* + * Reject calls on a stale handle (captured during a finished + * request, or carried across into a later request handled by + * the same worker context). See nxt_ruby_bind_req(). + */ + req = nxt_ruby_bind_req(bind); + if (req == NULL) { + return Qnil; + } if (req->content_length == 0) { return Qnil; @@ -166,13 +244,21 @@ nxt_ruby_stream_io_each(VALUE obj) static VALUE nxt_ruby_stream_io_read(VALUE obj, VALUE args) { - VALUE buf; - long copy_size, u_size; - nxt_ruby_ctx_t *rctx; + VALUE buf; + long copy_size, u_size; + nxt_ruby_io_bind_t *bind; + nxt_unit_request_info_t *req; - TypedData_Get_Struct(obj, nxt_ruby_ctx_t, &nxt_rctx_dt, rctx); + TypedData_Get_Struct(obj, nxt_ruby_io_bind_t, &nxt_rctx_dt, bind); + + /* See nxt_ruby_bind_req() — rejects stale handles captured + * across the request boundary or reused under a later request. */ + req = nxt_ruby_bind_req(bind); + if (req == NULL) { + return Qnil; + } - copy_size = rctx->req->content_length; + copy_size = req->content_length; if (RARRAY_LEN(args) > 0 && TYPE(RARRAY_PTR(args)[0]) == T_FIXNUM) { u_size = NUM2LONG(RARRAY_PTR(args)[0]); @@ -196,7 +282,7 @@ nxt_ruby_stream_io_read(VALUE obj, VALUE args) return Qnil; } - copy_size = nxt_unit_request_read(rctx->req, RSTRING_PTR(buf), copy_size); + copy_size = nxt_unit_request_read(req, RSTRING_PTR(buf), copy_size); if (RARRAY_LEN(args) > 1 && TYPE(RARRAY_PTR(args)[1]) == T_STRING) { @@ -220,15 +306,15 @@ nxt_ruby_stream_io_rewind(VALUE obj) static VALUE nxt_ruby_stream_io_puts(VALUE obj, VALUE args) { - nxt_ruby_ctx_t *rctx; + nxt_ruby_io_bind_t *bind; if (RARRAY_LEN(args) != 1) { return Qnil; } - TypedData_Get_Struct(obj, nxt_ruby_ctx_t, &nxt_rctx_dt, rctx); + TypedData_Get_Struct(obj, nxt_ruby_io_bind_t, &nxt_rctx_dt, bind); - nxt_ruby_stream_io_s_write(rctx, RARRAY_PTR(args)[0]); + nxt_ruby_stream_io_s_write(bind, RARRAY_PTR(args)[0]); return Qnil; } @@ -237,24 +323,26 @@ nxt_ruby_stream_io_puts(VALUE obj, VALUE args) static VALUE nxt_ruby_stream_io_write(VALUE obj, VALUE args) { - long len; - nxt_ruby_ctx_t *rctx; + long len; + nxt_ruby_io_bind_t *bind; if (RARRAY_LEN(args) != 1) { return Qnil; } - TypedData_Get_Struct(obj, nxt_ruby_ctx_t, &nxt_rctx_dt, rctx); + TypedData_Get_Struct(obj, nxt_ruby_io_bind_t, &nxt_rctx_dt, bind); - len = nxt_ruby_stream_io_s_write(rctx, RARRAY_PTR(args)[0]); + len = nxt_ruby_stream_io_s_write(bind, RARRAY_PTR(args)[0]); return LONG2FIX(len); } nxt_inline long -nxt_ruby_stream_io_s_write(nxt_ruby_ctx_t *rctx, VALUE val) +nxt_ruby_stream_io_s_write(nxt_ruby_io_bind_t *bind, VALUE val) { + nxt_unit_request_info_t *req; + if (nxt_slow_path(val == Qnil)) { return 0; } @@ -267,7 +355,23 @@ nxt_ruby_stream_io_s_write(nxt_ruby_ctx_t *rctx, VALUE val) } } - nxt_unit_req_error(rctx->req, "Ruby: %s", RSTRING_PTR(val)); + /* + * Apps legitimately write to rack.errors during at_exit hooks + * (running after the request handler returned and rctx->req was + * cleared at nxt_ruby.c:657) and may also retain rack.errors past + * the originating request. In both cases nxt_ruby_bind_req() + * returns NULL — route those messages through the NULL-context + * logger at ERR level so they still land in the unit log but do + * NOT get attributed to a later, unrelated request. ERR (not + * ALERT) avoids tripping the test suite's alert detector. + */ + req = nxt_ruby_bind_req(bind); + if (req == NULL) { + nxt_unit_log(NULL, NXT_UNIT_LOG_ERR, "Ruby: %s", RSTRING_PTR(val)); + return RSTRING_LEN(val); + } + + nxt_unit_req_error(req, "Ruby: %s", RSTRING_PTR(val)); return RSTRING_LEN(val); }