]> git.kaiwu.me - njs.git/commitdiff
Modules: fixed expire field truncation in shared dict state files.
authorDmitry Volyntsev <xeioex@nginx.com>
Fri, 20 Feb 2026 23:45:25 +0000 (15:45 -0800)
committerDmitry Volyntsev <xeioexception@gmail.com>
Tue, 24 Feb 2026 21:30:50 +0000 (13:30 -0800)
The njs_sprintf buffer for the expire field was sized for 10-digit
numbers, but current millisecond timestamps are 13 digits. This caused
silent truncation, making entries appear expired on a full restart.

The issue has been present since eca03622 (0.9.1), which introduced
the shared dictionary state file support.

nginx/ngx_js_shared_dict.c
nginx/t/js_shared_dict_state.t
nginx/t/js_shared_dict_state_timeout.t [new file with mode: 0644]

index 28eed0da45fe0a5f0a3ae85579f4906816882b4b..7eb13a700b51e38297d72cb773c862af5658f714 100644 (file)
@@ -1877,14 +1877,14 @@ ngx_js_dict_render_json(ngx_js_dict_t *dict, njs_chb_t *chain)
         }
 
         if (dict->timeout) {
-            len = sizeof(",\"expire\":1000000000");
+            len = sizeof(",\"expire\":18446744073709551615");
             dst = njs_chb_reserve(chain, len);
             if (dst == NULL) {
                 return NGX_ERROR;
             }
 
-            p = njs_sprintf(dst, dst + len, ",\"expire\":%ui",
-                            node->expire.key);
+            p = ngx_slprintf(dst, dst + len, ",\"expire\":%ui",
+                             node->expire.key);
             njs_chb_written(chain, p - dst);
         }
 
@@ -2943,6 +2943,14 @@ ngx_js_shared_dict_zone(ngx_conf_t *cf, ngx_command_t *cmd, void *conf,
         return NGX_CONF_ERROR;
     }
 
+#if (NGX_PTR_SIZE == 4)
+    if (timeout != 0 && file.len != 0) {
+        ngx_conf_log_error(NGX_LOG_EMERG, cf, 0, "state file is not supported "
+                           "on 32-bit platform when timeout is used");
+        return NGX_CONF_ERROR;
+    }
+#endif
+
     shm_zone = ngx_shared_memory_add(cf, &name, size, tag);
     if (shm_zone == NULL) {
         return NGX_CONF_ERROR;
index 32eef94869f7ec7bee2f47b4fc67bb7e71805c20..4267528e3d5e185de4aac1544f2dd594185312fa 100644 (file)
@@ -42,16 +42,11 @@ http {
     js_import test.js;
 
     js_shared_dict_zone zone=bar:64k type=string state=bar.json;
-    js_shared_dict_zone zone=waka:32k timeout=1000s type=number state=waka.json;
 
     server {
         listen       127.0.0.1:8080;
         server_name  localhost;
 
-        location /add {
-            js_content test.add;
-        }
-
         location /clear {
             js_content test.clear;
         }
@@ -64,10 +59,6 @@ http {
             js_content test.get;
         }
 
-        location /incr {
-            js_content test.incr;
-        }
-
         location /pop {
             js_content test.pop;
         }
@@ -90,30 +81,6 @@ $t->write_file('bar.json', <<EOF);
 EOF
 
 $t->write_file('test.js', <<'EOF');
-    function convertToValue(dict, v) {
-        if (dict.type == 'number') {
-            return parseInt(v);
-
-        } else if (v == 'empty') {
-            v = '';
-        }
-
-        return v;
-    }
-
-    function add(r) {
-        var dict = ngx.shared[r.args.dict];
-        var value = convertToValue(dict, r.args.value);
-
-        if (r.args.timeout) {
-            var timeout = Number(r.args.timeout);
-            r.return(200, dict.add(r.args.key, value, timeout));
-
-        } else {
-            r.return(200, dict.add(r.args.key, value));
-        }
-    }
-
     function clear(r) {
         var dict = ngx.shared[r.args.dict];
         var result = dict.clear();
@@ -139,21 +106,6 @@ $t->write_file('test.js', <<'EOF');
         r.return(200, val);
     }
 
-    function incr(r) {
-        var dict = ngx.shared[r.args.dict];
-        var def = r.args.def ? parseInt(r.args.def) : 0;
-
-        if (r.args.timeout) {
-            var timeout = Number(r.args.timeout);
-            var val = dict.incr(r.args.key, parseInt(r.args.by), def, timeout);
-            r.return(200, val);
-
-        } else {
-            var val = dict.incr(r.args.key, parseInt(r.args.by), def);
-            r.return(200, val);
-        }
-    }
-
     function pop(r) {
         var dict = ngx.shared[r.args.dict];
         var val = dict.pop(r.args.key);
@@ -169,18 +121,16 @@ $t->write_file('test.js', <<'EOF');
 
     function set(r) {
         var dict = ngx.shared[r.args.dict];
-        var value = convertToValue(dict, r.args.value);
+        var value = r.args.value;
 
-        if (r.args.timeout) {
-            var timeout = Number(r.args.timeout);
-            r.return(200, dict.set(r.args.key, value, timeout) === dict);
-
-        } else {
-            r.return(200, dict.set(r.args.key, value) === dict);
+        if (value == 'empty') {
+            value = '';
         }
+
+        r.return(200, dict.set(r.args.key, value) === dict);
     }
 
-    export default { add, clear, del, get, incr, pop, set };
+    export default { clear, del, get, pop, set };
 EOF
 
 $t->try_run('no js_shared_dict_zone with state=')->plan(11);
@@ -195,39 +145,36 @@ like(http_get('/get?dict=bar&key=abc'), qr/def/, 'get bar.abc');
 http_get('/set?dict=bar&key=waka&value=foo2');
 http_get('/delete?dict=bar&key=bar');
 
-http_get('/set?dict=waka&key=foo&value=42');
-
 select undef, undef, undef, 1.1;
 
-$t->reload();
+$t->stop();
+$t->run();
 
 my $bar_state = read_state($t, 'bar.json');
-my $waka_state = read_state($t, 'waka.json');
 
 is($bar_state->{waka}->{value}, 'foo2', 'get bar.waka from state');
+is($bar_state->{waka}->{expire}, undef,
+       'no expire field for bar.waka in state in non-timeout case');
 is($bar_state->{bar}, undef, 'no bar.bar in state');
-is($waka_state->{foo}->{value}, '42', 'get waka.foo from state');
-like($waka_state->{foo}->{expire}, qr/^\d+$/, 'waka.foo expire');
 
-http_get('/pop?dict=bar&key=FOO%20%0A');
+like(http_get('/get?dict=bar&key=waka'), qr/foo2/,
+       'get bar.waka after restart');
+like(http_get('/get?dict=bar&key=bar'), qr/undefined/,
+       'get bar.bar after restart');
 
-http_get('/incr?dict=waka&key=foo&by=1');
+http_get('/pop?dict=bar&key=FOO%20%0A');
 
 select undef, undef, undef, 1.1;
 
 $bar_state = read_state($t, 'bar.json');
-$waka_state = read_state($t, 'waka.json');
 
 is($bar_state->{'FOO \\n'}, undef, 'no bar.FOO \\n in state');
-is($waka_state->{foo}->{value}, '43', 'get waka.foo from state');
 
 http_get('/clear?dict=bar');
 
 select undef, undef, undef, 1.1;
 
-$bar_state = read_state($t, 'bar.json');
-
-is($bar_state->{waka}, undef, 'no bar.waka in state');
+is($t->read_file('bar.json'), "{}", 'empty dict saved as empty state');
 
 ###############################################################################
 
diff --git a/nginx/t/js_shared_dict_state_timeout.t b/nginx/t/js_shared_dict_state_timeout.t
new file mode 100644 (file)
index 0000000..245b084
--- /dev/null
@@ -0,0 +1,231 @@
+#!/usr/bin/perl
+
+# (C) Dmitry Volyntsev
+# (C) F5, Inc.
+
+# Tests for js_shared_dict_zone directive, state= with timeout= parameters.
+
+###############################################################################
+
+use warnings;
+use strict;
+
+use Test::More;
+use Socket qw/ CRLF /;
+
+BEGIN { use FindBin; chdir($FindBin::Bin); }
+
+use lib 'lib';
+use Test::Nginx;
+
+###############################################################################
+
+select STDERR; $| = 1;
+select STDOUT; $| = 1;
+
+eval { require JSON::PP; };
+plan(skip_all => "JSON::PP not installed") if $@;
+
+my $t = Test::Nginx->new()->has(qw/http/)
+       ->write_file_expand('nginx.conf', <<'EOF');
+
+%%TEST_GLOBALS%%
+
+daemon off;
+
+events {
+}
+
+http {
+    %%TEST_GLOBALS_HTTP%%
+
+    js_import test.js;
+
+    js_shared_dict_zone zone=waka:32k timeout=1000s type=number state=waka.json;
+    js_shared_dict_zone zone=exp:32k timeout=1000s type=string state=exp.json;
+
+    server {
+        listen       127.0.0.1:8080;
+        server_name  localhost;
+
+        location /get {
+            js_content test.get;
+        }
+
+        location /incr {
+            js_content test.incr;
+        }
+
+        location /set {
+            js_content test.set;
+        }
+    }
+}
+
+EOF
+
+my $now_ms = time() * 1000;
+my $past_expire = $now_ms - 3600000;
+my $future_expire = $now_ms + 3600000;
+
+$t->write_file('exp.json', <<EOF);
+{"past":{"value":"gone","expire":$past_expire},
+ "future":{"value":"here","expire":$future_expire},
+ "noexp":{"value":"fresh","expire":0}}
+EOF
+
+$t->write_file('test.js', <<'EOF');
+    function convertToValue(dict, v) {
+        if (dict.type == 'number') {
+            return parseInt(v);
+
+        } else if (v == 'empty') {
+            v = '';
+        }
+
+        return v;
+    }
+
+    function get(r) {
+        var dict = ngx.shared[r.args.dict];
+        var val = dict.get(r.args.key);
+
+        if (val == '') {
+            val = 'empty';
+
+        } else if (val === undefined) {
+            val = 'undefined';
+        }
+
+        r.return(200, val);
+    }
+
+    function incr(r) {
+        var dict = ngx.shared[r.args.dict];
+        var def = r.args.def ? parseInt(r.args.def) : 0;
+
+        if (r.args.timeout) {
+            var timeout = Number(r.args.timeout);
+            var val = dict.incr(r.args.key, parseInt(r.args.by), def, timeout);
+            r.return(200, val);
+
+        } else {
+            var val = dict.incr(r.args.key, parseInt(r.args.by), def);
+            r.return(200, val);
+        }
+    }
+
+    function set(r) {
+        var dict = ngx.shared[r.args.dict];
+        var value = convertToValue(dict, r.args.value);
+
+        if (r.args.timeout) {
+            var timeout = Number(r.args.timeout);
+            r.return(200, dict.set(r.args.key, value, timeout) === dict);
+
+        } else {
+            r.return(200, dict.set(r.args.key, value) === dict);
+        }
+    }
+
+    export default { get, incr, set };
+EOF
+
+$t->try_run('js_shared_dict_zone state with timeout no support on 32-bit')
+       ->plan(13);
+
+###############################################################################
+
+http_get('/set?dict=waka&key=foo&value=42');
+
+select undef, undef, undef, 1.1;
+
+$t->reload();
+
+my $waka_state = read_state($t, 'waka.json');
+
+is($waka_state->{foo}->{value}, '42', 'get waka.foo from state');
+like($waka_state->{foo}->{expire}, qr/^\d+$/, 'waka.foo expire');
+
+http_get('/incr?dict=waka&key=foo&by=1');
+
+select undef, undef, undef, 1.1;
+
+$waka_state = read_state($t, 'waka.json');
+
+is($waka_state->{foo}->{value}, '43', 'get waka.foo from state');
+
+like(http_get('/get?dict=exp&key=past'), qr/undefined/,
+       'expired entry cleaned on load');
+
+like(http_get('/get?dict=exp&key=future'), qr/here/,
+       'non-expired entry survives load');
+
+like(http_get('/get?dict=exp&key=noexp'), qr/fresh/,
+       'expire=0 entry accessible after load');
+
+my $before_set = time_ms();
+http_get('/set?dict=waka&key=exp_test&value=99&timeout=30000');
+my $after_set = time_ms();
+
+select undef, undef, undef, 1.1;
+
+$waka_state = read_state($t, 'waka.json');
+my $exp_val = $waka_state->{exp_test}->{expire};
+ok($exp_val >= $before_set + 25000 && $exp_val <= $after_set + 35000,
+       'expire field value in correct range for 30s timeout');
+
+my $expire_before = $waka_state->{exp_test}->{expire};
+
+$t->stop();
+$t->run();
+
+like(http_get('/get?dict=waka&key=exp_test'), qr/99/,
+       'value survives restart with valid expire');
+like(http_get('/get?dict=exp&key=future'), qr/here/,
+       'non-expired entry survives restart');
+like(http_get('/get?dict=exp&key=noexp'), qr/fresh/,
+       'expire=0 entry survives restart');
+
+select undef, undef, undef, 1.1;
+
+$waka_state = read_state($t, 'waka.json');
+is($waka_state->{exp_test}->{expire}, $expire_before,
+       'expire value preserved after restart');
+
+my $exp_state = read_state($t, 'exp.json');
+is($exp_state->{past}, undef, 'expired entry removed from state file');
+
+ok(defined $exp_state->{noexp}->{expire}
+       && $exp_state->{noexp}->{expire} > 0,
+       'expire=0 entry gets expire assigned in state');
+
+###############################################################################
+
+sub time_ms {
+       return time() * 1000;
+}
+
+sub decode_json {
+       my $json;
+       eval { $json = JSON::PP::decode_json(shift) };
+
+       if ($@) {
+               return "<failed to parse JSON>";
+       }
+
+       return $json;
+}
+
+sub read_state {
+       my ($self, $file) = @_;
+       my $json = $self->read_file($file);
+
+       if ($json) {
+               $json = decode_json($json);
+       }
+
+       return $json;
+}
+
+###############################################################################