From f25881e23d03e263e8913a505270e22e8fe377ad Mon Sep 17 00:00:00 2001 From: halibobo1205 Date: Wed, 4 Nov 2026 12:01:59 +0800 Subject: [PATCH 1/7] fix(db): handle exhausted BlockTime during flush For blocks at least 60 s old, Manager.applyBlock dereferenced the result of getNextValidTimeAfter(blockTime - maxFlushCount * 3 s) without a null check. Once the query reaches the final match of a finite cron expression, it returns null and block processing fails with a NullPointerException. With the default maxFlushCount = 1, this can occur on the first historical block after the final match. On the sync path, the exception disconnects the peer with BAD_BLOCK and can stall synchronization before the planned stop. Skip the flush adjustment when no fire time is left. The stop condition is unchanged. The new stop tests check the stop signal and persisted block height, not a real process exit. --- .../src/main/java/org/tron/core/db/Manager.java | 12 +++++++----- .../stop/BlockTimeStopDefaultFlushCountTest.java | 12 ++++++++++++ .../stop/BlockTimeStopLargerFlushCountTest.java | 12 ++++++++++++ 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 framework/src/test/java/org/tron/core/services/stop/BlockTimeStopDefaultFlushCountTest.java create mode 100644 framework/src/test/java/org/tron/core/services/stop/BlockTimeStopLargerFlushCountTest.java diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 9e5d2db7547..9c0592f9fb3 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -1045,11 +1045,13 @@ private void applyBlock(BlockCapsule block, List txs) updateFork(block); if (System.currentTimeMillis() - block.getTimeStamp() >= 60_000) { revokingStore.setMaxFlushCount(maxFlushCount); - if (Args.getInstance().getShutdownBlockTime() != null - && Args.getInstance().getShutdownBlockTime().getNextValidTimeAfter( - new Date(block.getTimeStamp() - maxFlushCount * 1000 * 3L)) - .compareTo(new Date(block.getTimeStamp())) <= 0) { - revokingStore.setMaxFlushCount(SnapshotManager.DEFAULT_MIN_FLUSH_COUNT); + CronExpression shutdownBlockTime = Args.getInstance().getShutdownBlockTime(); + if (shutdownBlockTime != null) { + Date nextStopTime = shutdownBlockTime.getNextValidTimeAfter( + new Date(block.getTimeStamp() - maxFlushCount * 1000 * 3L)); + if (nextStopTime != null && nextStopTime.compareTo(new Date(block.getTimeStamp())) <= 0) { + revokingStore.setMaxFlushCount(SnapshotManager.DEFAULT_MIN_FLUSH_COUNT); + } } if (latestSolidityNumShutDown > 0 && latestSolidityNumShutDown - block.getNum() <= maxFlushCount) { diff --git a/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopDefaultFlushCountTest.java b/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopDefaultFlushCountTest.java new file mode 100644 index 00000000000..c15f6faa7f8 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopDefaultFlushCountTest.java @@ -0,0 +1,12 @@ +package org.tron.core.services.stop; + +import org.tron.common.parameter.CommonParameter; + +public class BlockTimeStopDefaultFlushCountTest extends BlockTimeStopTest { + + @Override + protected void initParameter(CommonParameter parameter) { + super.initParameter(parameter); + parameter.getStorage().setMaxFlushCount(1); + } +} diff --git a/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopLargerFlushCountTest.java b/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopLargerFlushCountTest.java new file mode 100644 index 00000000000..31fa054e296 --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/stop/BlockTimeStopLargerFlushCountTest.java @@ -0,0 +1,12 @@ +package org.tron.core.services.stop; + +import org.tron.common.parameter.CommonParameter; + +public class BlockTimeStopLargerFlushCountTest extends BlockTimeStopTest { + + @Override + protected void initParameter(CommonParameter parameter) { + super.initParameter(parameter); + parameter.getStorage().setMaxFlushCount(20); + } +} From 1eef8cce55fa96abbd880addd083d8f75a5226be Mon Sep 17 00:00:00 2001 From: halibobo1205 Date: Wed, 4 Nov 2026 12:01:59 +0800 Subject: [PATCH 2/7] fix(common): stabilize cron year limits across locales MAX_YEAR came from Calendar.getInstance() when the class was first initialized, so it depended on the default locale at that moment. Under ja_JP_JP it is the era year plus 100, so every expression with an omitted year field or a wildcard year fails to parse. Under th_TH the limit is 643 years ahead instead of 100. Use GregorianCalendar, as getTimeAfter already does. Run each locale case in a fresh JVM so the class is first initialized under that default locale. Local patch: Quartz 2.5.2 and main still use Calendar.getInstance(), and no upstream issue was found as of 2026-11-04. Remove it when an upstream release uses the Gregorian year for MAX_YEAR regardless of the default locale's calendar. --- .../org/tron/common/cron/CronExpression.java | 3 +- .../common/cron/CronExpressionLocaleTest.java | 69 +++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 framework/src/test/java/org/tron/common/cron/CronExpressionLocaleTest.java diff --git a/common/src/main/java/org/tron/common/cron/CronExpression.java b/common/src/main/java/org/tron/common/cron/CronExpression.java index 0445ec2ce5b..ee1d57b403a 100644 --- a/common/src/main/java/org/tron/common/cron/CronExpression.java +++ b/common/src/main/java/org/tron/common/cron/CronExpression.java @@ -22,6 +22,7 @@ import java.text.ParseException; import java.util.Calendar; import java.util.Date; +import java.util.GregorianCalendar; import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -205,7 +206,7 @@ */ public final class CronExpression implements Serializable { - public static final int MAX_YEAR = Calendar.getInstance().get(Calendar.YEAR) + 100; + public static final int MAX_YEAR = new GregorianCalendar().get(Calendar.YEAR) + 100; private static final int SECOND = 0; private static final int MINUTE = 1; private static final int HOUR = 2; diff --git a/framework/src/test/java/org/tron/common/cron/CronExpressionLocaleTest.java b/framework/src/test/java/org/tron/common/cron/CronExpressionLocaleTest.java new file mode 100644 index 00000000000..094955ac584 --- /dev/null +++ b/framework/src/test/java/org/tron/common/cron/CronExpressionLocaleTest.java @@ -0,0 +1,69 @@ +package org.tron.common.cron; + +import static org.junit.Assert.assertEquals; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.List; +import java.util.Map; +import java.util.TimeZone; +import org.junit.Test; + +public class CronExpressionLocaleTest { + + public static void main(String[] args) throws Exception { + CronExpression cronExpression = new CronExpression("0 0 12 1 1 ?"); + cronExpression.setTimeZone(TimeZone.getTimeZone("UTC")); + Date next = cronExpression.getNextValidTimeAfter( + Date.from(Instant.parse("2026-06-01T00:00:00Z"))); + System.out.print((CronExpression.MAX_YEAR - new GregorianCalendar().get(Calendar.YEAR)) + + " " + next.toInstant() + " " + cronExpression.isSatisfiedBy(next)); + } + + @Test(timeout = 60000) + public void testYearLimitIgnoresDefaultCalendarSystem() throws Exception { + String expected = "100 2027-01-01T12:00:00Z true"; + assertEquals(expected, runWithDefaultLocale("ja", "JP", "JP")); + assertEquals(expected, runWithDefaultLocale("th", "TH", "")); + } + + private static String runWithDefaultLocale(String language, String country, String variant) + throws Exception { + String classPath = location(CronExpression.class) + File.pathSeparator + + location(CronExpressionLocaleTest.class); + List command = Arrays.asList( + System.getProperty("java.home") + File.separator + "bin" + File.separator + "java", + "-Duser.language=" + language, "-Duser.country=" + country, + "-Duser.variant=" + variant, "-cp", classPath, CronExpressionLocaleTest.class.getName()); + ProcessBuilder builder = new ProcessBuilder(command); + Map environment = builder.environment(); + environment.remove("JAVA_TOOL_OPTIONS"); + environment.remove("_JAVA_OPTIONS"); + environment.remove("JDK_JAVA_OPTIONS"); + Process process = builder.start(); + String output = read(process.getInputStream()); + String error = read(process.getErrorStream()); + assertEquals(error, 0, process.waitFor()); + return output; + } + + private static String location(Class type) throws Exception { + return new File(type.getProtectionDomain().getCodeSource().getLocation().toURI()).getPath(); + } + + private static String read(InputStream in) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + for (int n = in.read(buffer); n != -1; n = in.read(buffer)) { + out.write(buffer, 0, n); + } + return new String(out.toByteArray(), StandardCharsets.UTF_8); + } +} From 9088d9728e0670d08e68d2c416b156f967de95f9 Mon Sep 17 00:00:00 2001 From: halibobo1205 Date: Wed, 4 Nov 2026 12:01:59 +0800 Subject: [PATCH 3/7] fix(common): sync Quartz fixes and preserve weekday matching Backport selected CronExpression fixes from Quartz 2.5.1 and replace the nearest-weekday (W) lookup with a local one. This is not a plain sync: the vendored parser/evaluator is based on Quartz main 7b3b88bad8, whose CronExpression implementation matches v2.5.0, and the W code of 2.5.2 is deliberately not taken. Taken from Quartz 2.5.1: - quartz-scheduler/quartz#1284: "L" and "L-n" resolve to the real last day of short months, fixing the quartz-scheduler/quartz#1175 regression. - quartz-scheduler/quartz#1400: reject 'L' in the seconds, minutes and hours fields. - quartz-scheduler/quartz#1346, partly: ">= 60" and ">= 24" in the seconds, minutes and hours increment messages. The other fields keep "> 31", "> 12" and "> 7", because upstream's ">=" is wrong for them. Not taken: - quartz-scheduler/quartz#1424 (2.5.2): it can still skip a weekday moved forward, keeps the start day's time of day on a later W day, and leaves next and isSatisfiedBy disagreeing. - quartz-scheduler/quartz#1350: getTimeBefore is on an API the vendored class removed. - 464ca5cea4 (quartz-scheduler/quartz#1517), not included in Quartz 2.5.1 or 2.5.2: getTimeAfter can fail to terminate for "0 15 2 * * ?" in Australia/Lord_Howe after 2027-10-01T15:45Z. Local patch, findNearestWeekday and getNearestWeekday: look configured days up from two days before the current day, because a weekday moves at most two days, and take the first whose weekday is on or after the current day. This fixes the covered UTC month-skipping and W-list cases and avoids the Quartz 2.5.2 time-of-day regression. Each returned time in those regression tests also satisfies isSatisfiedBy. For example, "0 0 0 1W 5 ? 2027" passes the startup check and matches 2027-05-03. Behaviour change outside UTC, not a fix: on a DST fall-back a W day now follows the plain day-of-month rule, as reported for plain days in quartz-scheduler/quartz#1138. "0 40 23 30W 9 ? 1978" in Asia/Amman after 1978-09-29T20:40Z returned 21:40Z and now returns null. Upstream tracking: quartz-scheduler/quartz#1420, quartz-scheduler/quartz#1444 and the quartz-scheduler/quartz#1424 time-of-day regression, for which no issue was found as of 2026-11-04. Remove the local lookup when an upstream release passes the W tests here with isSatisfiedBy asserted. Take upstream's increment text once it says ">" for those fields. --- .../org/tron/common/cron/CronExpression.java | 79 ++-- .../tron/common/cron/CronExpressionTest.java | 439 +++++++++++++++++- .../tron/core/exception/TronErrorTest.java | 12 + 3 files changed, 481 insertions(+), 49 deletions(-) diff --git a/common/src/main/java/org/tron/common/cron/CronExpression.java b/common/src/main/java/org/tron/common/cron/CronExpression.java index ee1d57b403a..479dea70539 100644 --- a/common/src/main/java/org/tron/common/cron/CronExpression.java +++ b/common/src/main/java/org/tron/common/cron/CronExpression.java @@ -20,6 +20,8 @@ import java.io.Serializable; import java.text.ParseException; +import java.time.DayOfWeek; +import java.time.LocalDate; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; @@ -574,6 +576,9 @@ private int storeExpressionVals(int pos, String s, int type) addToSet(ALL_SPEC_INT, -1, incr, type); return i; } else if (c == 'L') { + if (type < DAY_OF_MONTH) { + throw new ParseException("'L' not expected in seconds, minutes or hours fields.", i); + } i++; if (type == DAY_OF_WEEK) { addToSet(7, 7, 0, type); @@ -632,9 +637,9 @@ private int storeExpressionVals(int pos, String s, int type) private void checkIncrementRange(int incr, int type, int idxPos) throws ParseException { if (incr > 59 && (type == SECOND || type == MINUTE)) { - throw new ParseException("Increment > 60 : " + incr, idxPos); + throw new ParseException("Increment >= 60 : " + incr, idxPos); } else if (incr > 23 && (type == HOUR)) { - throw new ParseException("Increment > 24 : " + incr, idxPos); + throw new ParseException("Increment >= 24 : " + incr, idxPos); } else if (incr > 31 && (type == DAY_OF_MONTH)) { throw new ParseException("Increment > 31 : " + incr, idxPos); } else if (incr > 7 && (type == DAY_OF_WEEK)) { @@ -1093,45 +1098,8 @@ public Date getTimeAfter(Date afterTime) { if (dayOfMSpec && !dayOfWSpec) { // get day by day of month rule final Optional smallestDay = findSmallestDay(day, mon, cl.get(Calendar.YEAR), daysOfMonth); - Optional smallestDayForWeekday = findSmallestDay(day, mon, cl.get(Calendar.YEAR), - nearestWeekdays); t = day; - day = -1; - if (smallestDayForWeekday.isPresent()) { - day = smallestDayForWeekday.get(); - - java.util.Calendar tcal = java.util.Calendar.getInstance(getTimeZone()); - tcal.set(Calendar.SECOND, 0); - tcal.set(Calendar.MINUTE, 0); - tcal.set(Calendar.HOUR_OF_DAY, 0); - tcal.set(Calendar.DAY_OF_MONTH, day); - tcal.set(Calendar.MONTH, mon - 1); - tcal.set(Calendar.YEAR, cl.get(Calendar.YEAR)); - - int ldom = getLastDayOfMonth(mon, cl.get(Calendar.YEAR)); - int dow = tcal.get(Calendar.DAY_OF_WEEK); - - if (dow == Calendar.SATURDAY && day == 1) { - day += 2; - } else if (dow == Calendar.SATURDAY) { - day -= 1; - } else if (dow == Calendar.SUNDAY && day == ldom) { - day -= 2; - } else if (dow == Calendar.SUNDAY) { - day += 1; - } - - - tcal.set(Calendar.SECOND, sec); - tcal.set(Calendar.MINUTE, min); - tcal.set(Calendar.HOUR_OF_DAY, hr); - tcal.set(Calendar.DAY_OF_MONTH, day); - tcal.set(Calendar.MONTH, mon - 1); - Date nTime = tcal.getTime(); - if (nTime.before(afterTime)) { - day = -1; - } - } + day = findNearestWeekday(t, mon, cl.get(Calendar.YEAR)); if (smallestDay.isPresent()) { if (day == -1 || smallestDay.get() < day) { day = smallestDay.get(); @@ -1396,7 +1364,7 @@ private Optional findSmallestDay(int day, int mon, int year, TreeSet d - LAST_DAY_OFFSET_START + 1) .orElse(Integer.MAX_VALUE); @@ -1404,9 +1372,34 @@ private Optional findSmallestDay(int day, int mon, int year, TreeSet st = set.subSet(day, LAST_DAY_OFFSET_START); // make sure we don't over-run a short month, such as february if (!st.isEmpty() && st.first() < smallestDay && st.first() <= lastDay) { - smallestDay = st.first(); + return Optional.of(st.first()); + } + + return smallestDay == Integer.MAX_VALUE ? Optional.empty() + : Optional.of(smallestDay + lastDay - LAST_DAY_OFFSET_START + 1); + } + + private int findNearestWeekday(int day, int mon, int year) { + Optional nominal = findSmallestDay(day > 3 ? day - 2 : 1, mon, year, + nearestWeekdays); + while (nominal.isPresent()) { + int weekday = getNearestWeekday(nominal.get(), mon, year); + if (weekday >= day) { + return weekday; + } + nominal = findSmallestDay(nominal.get() + 1, mon, year, nearestWeekdays); } + return -1; + } - return smallestDay == Integer.MAX_VALUE ? Optional.empty() : Optional.of(smallestDay); + private int getNearestWeekday(int day, int mon, int year) { + DayOfWeek dayOfWeek = LocalDate.of(year, mon, day).getDayOfWeek(); + if (dayOfWeek == DayOfWeek.SATURDAY) { + return day == 1 ? 3 : day - 1; + } + if (dayOfWeek == DayOfWeek.SUNDAY) { + return day == getLastDayOfMonth(mon, year) ? day - 2 : day + 1; + } + return day; } } diff --git a/framework/src/test/java/org/tron/common/cron/CronExpressionTest.java b/framework/src/test/java/org/tron/common/cron/CronExpressionTest.java index 1c8bdf86134..06b9d1dacb5 100644 --- a/framework/src/test/java/org/tron/common/cron/CronExpressionTest.java +++ b/framework/src/test/java/org/tron/common/cron/CronExpressionTest.java @@ -19,12 +19,16 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; import static org.junit.Assert.fail; import java.text.ParseException; +import java.time.Instant; import java.util.Calendar; import java.util.Date; import java.util.Locale; +import java.util.TimeZone; import org.junit.Test; public class CronExpressionTest { @@ -72,6 +76,151 @@ public void testIsSatisfiedBy() throws Exception { cal = Calendar.getInstance(); cal.set(2005, Calendar.JUNE, 1, 10, 14, 0); assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.JUNE, 30, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.MARCH, 1, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(Calendar.DAY_OF_MONTH, 31); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.MARCH, 1, 10, 16, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.MARCH, 1, 10, 14, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.FEBRUARY, 1, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(Calendar.DAY_OF_MONTH, 28); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.FEBRUARY, 1, 10, 16, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal = Calendar.getInstance(); + cal.set(2005, Calendar.FEBRUARY, 1, 10, 14, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 12 * ? 2005"); + cal.set(2005, Calendar.DECEMBER, 12, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 11, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 13, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + } + + @Test + public void testIsSatisfiedByLastDayOfMonth() throws Exception { + Calendar cal = Calendar.getInstance(); + + CronExpression cronExpression = new CronExpression("0 15 10 L * ? 2005"); + cal.set(2005, Calendar.DECEMBER, 31, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 30, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.SEPTEMBER, 30, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.SEPTEMBER, 29, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 L 2 ? 2005"); + cal.set(2005, Calendar.FEBRUARY, 28, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.FEBRUARY, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 L 2 ? 2004"); + cal.set(2004, Calendar.FEBRUARY, 29, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2004, Calendar.FEBRUARY, 28, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + } + + @Test + public void testIsSatisfiedByLastDayOfMonthWithOffset() throws Exception { + Calendar cal = Calendar.getInstance(); + + CronExpression cronExpression = new CronExpression("0 15 10 L-2 * ? 2005"); + cal.set(2005, Calendar.DECEMBER, 29, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 28, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 30, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.DECEMBER, 31, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 L-1 * ? 2005"); + cal.set(2005, Calendar.SEPTEMBER, 29, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.SEPTEMBER, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.SEPTEMBER, 28, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.SEPTEMBER, 30, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 L-3 2 ? 2005"); + cal.set(2005, Calendar.FEBRUARY, 25, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.FEBRUARY, 24, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.FEBRUARY, 26, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2005, Calendar.FEBRUARY, 28, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cronExpression = new CronExpression("0 15 10 L-3 2 ? 2000"); + cal.set(2000, Calendar.FEBRUARY, 26, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2000, Calendar.FEBRUARY, 25, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2000, Calendar.FEBRUARY, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2000, Calendar.FEBRUARY, 29, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + } + + @Test + public void testNextValidTimeAfterLastDayOfShortMonth() throws Exception { + assertNextFireTimesInUtc("0 0 0 L 4 ? 2027", "2027-04-01T00:00:00Z", "2027-04-30T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 L * ? 2027", "2027-04-01T00:00:00Z", "2027-04-30T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 L-1 4 ? 2027", "2027-04-01T00:00:00Z", + "2027-04-29T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 L 2 ? 2028", "2027-04-01T00:00:00Z", "2028-02-29T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 L 2 ? 2027", "2027-01-01T00:00:00Z", "2027-02-28T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 L-3 2 ? 2028", "2027-04-01T00:00:00Z", + "2028-02-26T00:00:00Z"); } @Test @@ -100,7 +249,27 @@ public void testIsValidExpression() { assertFalse(CronExpression.isValidExpression(" 0 15 10 ? * 6#3 2014-2012")); assertTrue(CronExpression.isValidExpression("0 0 20-18 ? * MON,WED,FRI")); assertTrue(CronExpression.isValidExpression("0 0/30 17-9 * 10-9 ? 2022")); + assertTrue(CronExpression.isValidExpression("* * * * * ?")); + assertTrue(CronExpression.isValidExpression("0 * 5 * * ?")); + assertTrue(CronExpression.isValidExpression("0 15 10 L-1W,L-1 * ? 2010")); + assertFalse(CronExpression.isValidExpression("Ralf 30 * * * ?")); + assertFalse(CronExpression.isValidExpression("0 30 Ralf * * ?")); + assertFalse(CronExpression.isValidExpression("kilroy was here")); + assertFalse(CronExpression.isValidExpression("L 30 * * * ?")); + assertFalse(CronExpression.isValidExpression("0 L * * * ?")); + assertFalse(CronExpression.isValidExpression("0 0 L * * ?")); + } + @Test + public void testIncrementRangeBoundary() { + assertEquals("Increment >= 60 : 60", assertThrows(ParseException.class, + () -> new CronExpression("/60 0 8-18 ? * 2-6")).getMessage()); + assertEquals("Increment >= 60 : 60", assertThrows(ParseException.class, + () -> new CronExpression("0 /60 8-18 ? * 2-6")).getMessage()); + assertEquals("Increment >= 24 : 24", assertThrows(ParseException.class, + () -> new CronExpression("0 0 /24 ? * 2-6")).getMessage()); + assertEquals("Increment > 12 : 13", assertThrows(ParseException.class, + () -> new CronExpression("0 0 0 ? /13 2-6")).getMessage()); } @Test @@ -116,11 +285,41 @@ public void testLastDayOffset() throws Exception { cal.set(2010, Calendar.OCTOBER, 28, 10, 15, 0); assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + // last day - 2 for February + cal.set(2010, Calendar.FEBRUARY, 26, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.FEBRUARY, 25, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + // last day - 2 for June + cal.set(2010, Calendar.JUNE, 28, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.JUNE, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + cronExpression = new CronExpression("0 15 10 L-5W * ? 2010"); // last day - 5 cal.set(2010, Calendar.OCTOBER, 26, 10, 15, 0); assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + cal.set(2010, Calendar.OCTOBER, 25, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.OCTOBER, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + // last day - 5 (September has 30 days) + cal.set(2010, Calendar.SEPTEMBER, 24, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.SEPTEMBER, 23, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.SEPTEMBER, 25, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + cronExpression = new CronExpression("0 15 10 L-1 * ? 2010"); // last day - 1 cal.set(2010, Calendar.OCTOBER, 30, 10, 15, 0); @@ -142,12 +341,28 @@ public void testLastDayOffset() throws Exception { cal.set(2010, Calendar.OCTOBER, 30, 10, 15, 0); assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + cal.set(2010, Calendar.FEBRUARY, 1, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.FEBRUARY, 28, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + + cal.set(2010, Calendar.FEBRUARY, 27, 10, 15, 0); + assertFalse(cronExpression.isSatisfiedBy(cal.getTime())); + cronExpression = new CronExpression("0 15 10 L-1W,L-1 * ? 2010"); // nearest weekday to last day - 1 (29th is a friday in 2010) cal.set(2010, Calendar.OCTOBER, 29, 10, 15, 0); assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); // last day - 1 cal.set(2010, Calendar.OCTOBER, 30, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + // nearest weekday to last day - 1 (26th is a friday in 2010) + cal.set(2010, Calendar.FEBRUARY, 26, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + // last day - 1 + cal.set(2010, Calendar.FEBRUARY, 27, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); cronExpression = new CronExpression("0 15 10 2W,16 * ? 2010"); // nearest weekday to the 2nd of the month (1st is a friday in 2010) @@ -159,7 +374,12 @@ public void testLastDayOffset() throws Exception { cal.set(2010, Calendar.OCTOBER, 16, 10, 15, 0); assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + // 2nd is a Tuesday in November 2010 + cal.set(2010, Calendar.NOVEMBER, 2, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); + cal.set(2010, Calendar.NOVEMBER, 16, 10, 15, 0); + assertTrue(cronExpression.isSatisfiedBy(cal.getTime())); } @Test @@ -262,7 +482,7 @@ public void testQtz96() throws ParseException { new CronExpression("/120 0 8-18 ? * 2-6"); fail("Cron did not validate bad range interval in '_blank/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 60 : 120", e.getMessage()); + assertEquals("Increment >= 60 : 120", e.getMessage()); } // Test case 2 @@ -270,7 +490,7 @@ public void testQtz96() throws ParseException { new CronExpression("0/120 0 8-18 ? * 2-6"); fail("Cron did not validate bad range interval in in '0/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 60 : 120", e.getMessage()); + assertEquals("Increment >= 60 : 120", e.getMessage()); } // Test case 3 @@ -294,7 +514,7 @@ public void testQtz96() throws ParseException { new CronExpression("0 /120 8-18 ? * 2-6"); fail("Cron did not validate bad range interval in '_blank/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 60 : 120", e.getMessage()); + assertEquals("Increment >= 60 : 120", e.getMessage()); } // Test case 2 @@ -302,7 +522,7 @@ public void testQtz96() throws ParseException { new CronExpression("0 0/120 8-18 ? * 2-6"); fail("Cron did not validate bad range interval in in '0/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 60 : 120", e.getMessage()); + assertEquals("Increment >= 60 : 120", e.getMessage()); } // Test case 3 @@ -326,7 +546,7 @@ public void testQtz96() throws ParseException { new CronExpression("0 0 /120 ? * 2-6"); fail("Cron did not validate bad range interval in '_blank/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 24 : 120", e.getMessage()); + assertEquals("Increment >= 24 : 120", e.getMessage()); } // Test case 2 @@ -334,7 +554,7 @@ public void testQtz96() throws ParseException { new CronExpression("0 0 0/120 ? * 2-6"); fail("Cron did not validate bad range interval in in '0/xxx' form"); } catch (ParseException e) { - assertEquals("Increment > 24 : 120", e.getMessage()); + assertEquals("Increment >= 24 : 120", e.getMessage()); } // Test case 3 @@ -448,4 +668,211 @@ public void testQtz96() throws ParseException { } } + @Test + public void test1420() throws Exception { + Date after = localTime(2025, Calendar.NOVEMBER, 18, 10, 15, 0); + + assertNextFireTimes(new CronExpression("0 0 0 1W * ?"), after, + localTime(2025, Calendar.DECEMBER, 1, 0, 0, 0), + localTime(2026, Calendar.JANUARY, 1, 0, 0, 0), + localTime(2026, Calendar.FEBRUARY, 2, 0, 0, 0), + localTime(2026, Calendar.MARCH, 2, 0, 0, 0), + localTime(2026, Calendar.APRIL, 1, 0, 0, 0), + localTime(2026, Calendar.MAY, 1, 0, 0, 0), + localTime(2026, Calendar.JUNE, 1, 0, 0, 0), + localTime(2026, Calendar.JULY, 1, 0, 0, 0), + localTime(2026, Calendar.AUGUST, 3, 0, 0, 0), + localTime(2026, Calendar.SEPTEMBER, 1, 0, 0, 0), + localTime(2026, Calendar.OCTOBER, 1, 0, 0, 0), + localTime(2026, Calendar.NOVEMBER, 2, 0, 0, 0), + localTime(2026, Calendar.DECEMBER, 1, 0, 0, 0)); + + assertNextFireTimes(new CronExpression("59 59 23 L * ?"), after, + localTime(2025, Calendar.NOVEMBER, 30, 23, 59, 59), + localTime(2025, Calendar.DECEMBER, 31, 23, 59, 59), + localTime(2026, Calendar.JANUARY, 31, 23, 59, 59), + localTime(2026, Calendar.FEBRUARY, 28, 23, 59, 59), + localTime(2026, Calendar.MARCH, 31, 23, 59, 59), + localTime(2026, Calendar.APRIL, 30, 23, 59, 59), + localTime(2026, Calendar.MAY, 31, 23, 59, 59), + localTime(2026, Calendar.JUNE, 30, 23, 59, 59), + localTime(2026, Calendar.JULY, 31, 23, 59, 59), + localTime(2026, Calendar.AUGUST, 31, 23, 59, 59), + localTime(2026, Calendar.SEPTEMBER, 30, 23, 59, 59), + localTime(2026, Calendar.OCTOBER, 31, 23, 59, 59), + localTime(2026, Calendar.NOVEMBER, 30, 23, 59, 59), + localTime(2026, Calendar.DECEMBER, 31, 23, 59, 59)); + + assertTrue(CronExpression.isValidExpression("0 0 0 1W * ?")); + assertTrue(CronExpression.isValidExpression("59 59 23 L * ?")); + } + + @Test + public void testNearestWeekdayOnLaterDayStartsAtFirstHour() throws Exception { + assertNextFireTimesInUtc("0 0 0,12 15W * ?", "2027-06-01T00:01:00Z", + "2027-06-15T00:00:00Z", "2027-06-15T12:00:00Z", "2027-07-15T00:00:00Z"); + } + + @Test + public void testNearestWeekdayOnLaterDayStartsAtFirstMinute() throws Exception { + assertNextFireTimesInUtc("0 * * 15W * ?", "2027-06-01T00:01:00Z", + "2027-06-15T00:00:00Z", "2027-06-15T00:01:00Z"); + } + + @Test + public void testNearestWeekdayOnLaterDayStartsAtFirstSecond() throws Exception { + assertNextFireTimesInUtc("*/20 * * 15W * ?", "2027-06-01T00:01:00Z", + "2027-06-15T00:00:00Z", "2027-06-15T00:00:20Z", "2027-06-15T00:00:40Z"); + } + + @Test + public void testNearestWeekdayMovedBackStartsAtFirstFireTime() throws Exception { + assertNextFireTimesInUtc("0 0 0,12 15W * ?", "2026-08-01T00:01:00Z", + "2026-08-14T00:00:00Z", "2026-08-14T12:00:00Z", "2026-09-15T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0,12 15W * ?", "2026-08-15T00:00:00Z", + "2026-09-15T00:00:00Z"); + assertNextFireTimesInUtc("0 0 12 LW 10 ? 2027", "2027-10-01T00:00:00Z", + "2027-10-29T12:00:00Z"); + } + + @Test + public void testLastWeekdayOnLaterDayStartsAtFirstFireTime() throws Exception { + assertNextFireTimesInUtc("0 0 0,12 LW * ?", "2027-07-01T00:01:00Z", + "2027-07-30T00:00:00Z", "2027-07-30T12:00:00Z", "2027-08-31T00:00:00Z"); + } + + @Test + public void testLastDayOffsetWeekdayOnLaterDayStartsAtFirstFireTime() throws Exception { + assertNextFireTimesInUtc("0 0/30 9 L-2W * ?", "2027-07-01T09:10:00Z", + "2027-07-29T09:00:00Z", "2027-07-29T09:30:00Z"); + assertNextFireTimesInUtc("0 0 0,12 L-1W 10 ? 2027", "2027-10-01T00:00:00Z", + "2027-10-29T00:00:00Z", "2027-10-29T12:00:00Z"); + assertNextFireTimesInUtc("0 0 0,12 L-2W 8 ? 2027", "2027-08-01T00:00:00Z", + "2027-08-30T00:00:00Z", "2027-08-30T12:00:00Z"); + } + + @Test + public void testNearestWeekdayMovedForwardIsSatisfied() throws Exception { + assertNextFireTimesInUtc("0 0 0 1W 5 ? 2027", "2027-04-30T12:00:00Z", + "2027-05-03T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 1W 5 ? 2027", "2027-05-02T00:00:00Z", + "2027-05-03T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0,12 15W * ?", "2027-08-01T00:00:00Z", + "2027-08-16T00:00:00Z", "2027-08-16T12:00:00Z", "2027-09-15T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0 1W * ?", "2027-05-01T00:00:00Z", "2027-05-03T00:00:00Z"); + assertNextFireTimesInUtc("0 0 0,12 1W 5 ? 2027", "2027-05-03T00:00:00Z", + "2027-05-03T12:00:00Z"); + } + + @Test + public void testNearestWeekdayListDoesNotSkipMonth() throws Exception { + assertNextFireTimesInUtc("0 0 10 3W,20W 4 ?", "2027-04-02T10:00:00Z", + "2027-04-20T10:00:00Z", "2028-04-03T10:00:00Z"); + } + + @Test(timeout = 10000) + public void testNearestWeekdayInDaylightSavingGapTerminates() throws Exception { + CronExpression cronExpression = new CronExpression("0 30 1 29W 2 ?"); + cronExpression.setTimeZone(TimeZone.getTimeZone("Asia/Jerusalem")); + Date after = Date.from(Instant.parse("2003-02-28T12:00:00Z")); + + Date next = cronExpression.getNextValidTimeAfter(after); + assertEquals(Instant.parse("2004-02-26T23:30:00Z"), next.toInstant()); + assertTrue(cronExpression.isSatisfiedBy(next)); + } + + @Test + public void testNearestWeekdayBeforeLaterDayOfMonthStillFires() throws Exception { + assertNextFireTimesInUtc("18 0 22 L,3W,LW 3 ? 2001", "2001-03-02T22:00:18Z", + "2001-03-30T22:00:18Z", "2001-03-31T22:00:18Z"); + assertNextFireTimesInUtc("0 0 10 5,20W 4 ? 2027", "2027-04-01T00:00:00Z", + "2027-04-05T10:00:00Z", "2027-04-20T10:00:00Z"); + } + + @Test(timeout = 10000) + public void testNearestWeekdayDoesNotFireInDaylightSavingGap() throws Exception { + TimeZone amman = TimeZone.getTimeZone("Asia/Amman"); + Date after = Date.from(Instant.parse("2021-03-20T00:00:00Z")); + + CronExpression cronExpression = new CronExpression("0 30 0 27W 3 ? 2021"); + cronExpression.setTimeZone(amman); + assertNull(cronExpression.getNextValidTimeAfter(after)); + assertFalse(cronExpression.isSatisfiedBy(Date.from(Instant.parse("2021-03-25T22:30:00Z")))); + + cronExpression = new CronExpression("0 30 0,5 27W 3 ? 2021"); + cronExpression.setTimeZone(amman); + Date next = cronExpression.getNextValidTimeAfter(after); + assertEquals(Instant.parse("2021-03-26T02:30:00Z"), next.toInstant()); + assertTrue(cronExpression.isSatisfiedBy(next)); + } + + @Test(timeout = 10000) + public void testNearestWeekdayOnSkippedDayNeverGoesBackwards() throws Exception { + CronExpression cronExpression = new CronExpression("0 5 0,2 LW,L,1 12 ? 2011"); + cronExpression.setTimeZone(TimeZone.getTimeZone("Pacific/Apia")); + Date after = Date.from(Instant.parse("2011-12-30T10:05:00Z")); + + Date next = cronExpression.getNextValidTimeAfter(after); + assertEquals(Instant.parse("2011-12-30T12:05:00Z"), next.toInstant()); + assertTrue(cronExpression.isSatisfiedBy(next)); + } + + @Test + public void testNearestWeekdayIgnoresDefaultCalendarSystem() throws Exception { + Locale defaultLocale = Locale.getDefault(); + try { + for (Locale locale : new Locale[] {new Locale("th", "TH"), new Locale("ja", "JP", "JP")}) { + Locale.setDefault(locale); + assertNextFireTimesInUtc("0 0 0 15W * ?", "2027-06-01T00:00:00Z", + "2027-06-15T00:00:00Z", "2027-07-15T00:00:00Z"); + } + } finally { + Locale.setDefault(defaultLocale); + } + } + + @Test + public void testNextFireTimeIgnoresMilliseconds() throws Exception { + CronExpression cronExpression = new CronExpression("* * * * * ?"); + cronExpression.setTimeZone(TimeZone.getTimeZone("UTC")); + Date next = cronExpression.getNextValidTimeAfter( + Date.from(Instant.parse("2027-06-01T00:00:00.700Z"))); + assertEquals(Instant.parse("2027-06-01T00:00:01Z"), next.toInstant()); + assertTrue(cronExpression.isSatisfiedBy(next)); + + cronExpression = new CronExpression("0 0 12 * * ?"); + cronExpression.setTimeZone(TimeZone.getTimeZone("UTC")); + assertTrue(cronExpression.isSatisfiedBy(Date.from(Instant.parse("2027-06-01T12:00:00.500Z")))); + assertFalse(cronExpression.isSatisfiedBy(Date.from(Instant.parse("2027-06-01T12:00:01Z")))); + } + + private static Date localTime(int year, int month, int day, int hour, int minute, + int second) { + Calendar cal = Calendar.getInstance(); + cal.clear(); + cal.set(year, month, day, hour, minute, second); + return cal.getTime(); + } + + private static void assertNextFireTimes(CronExpression cronExpression, Date after, + Date... expected) { + Date time = after; + for (Date next : expected) { + time = cronExpression.getTimeAfter(time); + assertEquals(next, time); + assertTrue(cronExpression.isSatisfiedBy(time)); + } + } + + private static void assertNextFireTimesInUtc(String expression, String after, + String... expected) throws ParseException { + CronExpression cronExpression = new CronExpression(expression); + cronExpression.setTimeZone(TimeZone.getTimeZone("UTC")); + Date time = Date.from(Instant.parse(after)); + for (String next : expected) { + time = cronExpression.getNextValidTimeAfter(time); + assertEquals(Instant.parse(next), time.toInstant()); + assertTrue(cronExpression.isSatisfiedBy(time), next); + } + } } diff --git a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java index ab67437dfe7..c2495c03082 100644 --- a/framework/src/test/java/org/tron/core/exception/TronErrorTest.java +++ b/framework/src/test/java/org/tron/core/exception/TronErrorTest.java @@ -188,6 +188,18 @@ public void shutdownBlockTimeInitTest() { assertEquals(TronError.ErrCode.AUTO_STOP_PARAMS, thrown.getErrCode()); } + @Test + public void shutdownBlockTimeLastDayInSecondsTest() { + Map params = new HashMap<>(); + params.put("node.shutdown.BlockTime", "L 30 * * * ?"); + params.put("storage.db.directory", "database"); + Config config = ConfigFactory.defaultOverrides() + .withFallback(ConfigFactory.parseMap(params)) + .withFallback(ConfigFactory.defaultReference()); + TronError thrown = assertThrows(TronError.class, () -> Args.applyConfigParams(config)); + assertEquals(TronError.ErrCode.AUTO_STOP_PARAMS, thrown.getErrCode()); + } + @Test public void testThrowIfUnsupportedJavaVersion() { runArchTest("x86_64", "1.8", false); From 344e86f1d81c146b3a048cf4720704e8931e4d7a Mon Sep 17 00:00:00 2001 From: halibobo1205 Date: Wed, 4 Nov 2026 12:01:59 +0800 Subject: [PATCH 4/7] fix(common): compare last-day offsets as calendar days findSmallestDay compared a plain day with the slot index of an "L-n" entry, which equals the real day only in 31-day months. In February a later plain day won: "0 0 12 L-3,26 2 ? 2022" skipped 2022-02-25 and fired first on the 26th. Convert the entry to its calendar day before comparing. This adds no syntax. Local patch: Quartz 2.5.2 and main compare the same way and no upstream issue was found as of 2026-11-04. Remove it when an upstream release selects the earliest actual calendar day in mixed plain-day and L-n lists. --- .../main/java/org/tron/common/cron/CronExpression.java | 5 ++--- .../java/org/tron/common/cron/CronExpressionTest.java | 8 ++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/common/src/main/java/org/tron/common/cron/CronExpression.java b/common/src/main/java/org/tron/common/cron/CronExpression.java index 479dea70539..7a63955db22 100644 --- a/common/src/main/java/org/tron/common/cron/CronExpression.java +++ b/common/src/main/java/org/tron/common/cron/CronExpression.java @@ -1365,7 +1365,7 @@ private Optional findSmallestDay(int day, int mon, int year, TreeSet d - LAST_DAY_OFFSET_START + 1) + .map(d -> lastDay - (LAST_DAY_OFFSET_END - d)) .orElse(Integer.MAX_VALUE); // For "1", "2", etc. @@ -1375,8 +1375,7 @@ private Optional findSmallestDay(int day, int mon, int year, TreeSet Date: Wed, 4 Nov 2026 12:01:59 +0800 Subject: [PATCH 5/7] fix(config): evaluate BlockTime cron in UTC node.shutdown.BlockTime was interpreted in the JVM default time zone although it is matched against block timestamps, so the same value matched 8 hours apart on a UTC node and an Asia/Shanghai node. Pin the expression to UTC in Args, and document in reference.conf and config.conf how the stop matches a block. The exact block-timestamp match and persistence requirements are unchanged. Compatibility: operators using a non-UTC JVM should review the value and adjust it if they need to preserve the previous firing times. Interpreting it in UTC can leave a finite expression with no match after the head block time; the existing startup check then rejects it with AUTO_STOP_PARAMS. --- common/src/main/resources/reference.conf | 6 +++- .../java/org/tron/core/config/args/Args.java | 6 +++- framework/src/main/resources/config.conf | 6 +++- .../org/tron/core/config/args/ArgsTest.java | 30 +++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/common/src/main/resources/reference.conf b/common/src/main/resources/reference.conf index 3b45be3cdb2..fe7c28c8934 100644 --- a/common/src/main/resources/reference.conf +++ b/common/src/main/resources/reference.conf @@ -149,8 +149,12 @@ node.discovery = { } # Custom stop condition +# BlockTime stops the node only at a block whose timestamp equals a cron time exactly (UTC). +# Mainnet produces a block every 3 s, so the seconds field must be a multiple of 3, and +# HH:00:03 / HH:00:06 UTC (HH = 00, 06, 12, 18) normally have no block (maintenance skip). +# If no block lands on that second (e.g. a missed slot), the node keeps running. # node.shutdown = { -# BlockTime = "54 59 08 * * ?" # if block header time in persistent db matched +# BlockTime = "54 59 08 * * ?" # cron evaluated in UTC, if block header time in persistent db matched # BlockHeight = 33350800 # if block header height in persistent db matched # BlockCount = 12 # block sync count after node start # } diff --git a/framework/src/main/java/org/tron/core/config/args/Args.java b/framework/src/main/java/org/tron/core/config/args/Args.java index a594576c96d..0dd2b8f3b88 100644 --- a/framework/src/main/java/org/tron/core/config/args/Args.java +++ b/framework/src/main/java/org/tron/core/config/args/Args.java @@ -18,6 +18,7 @@ import java.net.InetAddress; import java.net.InetSocketAddress; import java.text.ParseException; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -28,6 +29,7 @@ import java.util.Map; import java.util.Properties; import java.util.Set; +import java.util.TimeZone; import java.util.concurrent.BlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; @@ -676,7 +678,9 @@ private static void applyNodeConfig(NodeConfig nc) { String shutdownBlockTime = nc.getShutdownBlockTime(); if (!shutdownBlockTime.isEmpty()) { try { - PARAMETER.shutdownBlockTime = new CronExpression(shutdownBlockTime); + CronExpression blockTime = new CronExpression(shutdownBlockTime); + blockTime.setTimeZone(TimeZone.getTimeZone(ZoneOffset.UTC)); + PARAMETER.shutdownBlockTime = blockTime; } catch (ParseException e) { throw new TronError(e, TronError.ErrCode.AUTO_STOP_PARAMS); } diff --git a/framework/src/main/resources/config.conf b/framework/src/main/resources/config.conf index 24aade2c71d..e764f7e32b9 100644 --- a/framework/src/main/resources/config.conf +++ b/framework/src/main/resources/config.conf @@ -50,8 +50,12 @@ node.discovery = { } # Custom stop condition +# BlockTime stops the node only at a block whose timestamp equals a cron time exactly (UTC). +# Mainnet produces a block every 3 s, so the seconds field must be a multiple of 3, and +# HH:00:03 / HH:00:06 UTC (HH = 00, 06, 12, 18) normally have no block (maintenance skip). +# If no block lands on that second (e.g. a missed slot), the node keeps running. # node.shutdown = { -# BlockTime = "54 59 08 * * ?" # if block header time in persistent db matched. +# BlockTime = "54 59 08 * * ?" # cron evaluated in UTC, if block header time in persistent db matched. # BlockHeight = 33350800 # if block header height in persistent db matched. # BlockCount = 12 # block sync count after node start. # } diff --git a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java index 07714b240ad..2cca9082cc3 100644 --- a/framework/src/test/java/org/tron/core/config/args/ArgsTest.java +++ b/framework/src/test/java/org/tron/core/config/args/ArgsTest.java @@ -29,9 +29,12 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.InetAddress; +import java.time.Instant; import java.util.Arrays; +import java.util.Date; import java.util.HashMap; import java.util.Map; +import java.util.TimeZone; import lombok.extern.slf4j.Slf4j; import org.junit.After; import org.junit.Assert; @@ -41,6 +44,7 @@ import org.slf4j.LoggerFactory; import org.tron.common.TestConstants; import org.tron.common.args.GenesisBlock; +import org.tron.common.cron.CronExpression; import org.tron.common.parameter.CommonParameter; import org.tron.common.utils.ByteArray; import org.tron.common.utils.DecodeUtil; @@ -196,6 +200,32 @@ public void testIpFromLibP2p() Assert.assertNotEquals(configuredExternalIp, parameter.getNodeExternalIp()); } + @Test + public void testShutdownBlockTimeEvaluatedInUtc() { + TimeZone defaultZone = TimeZone.getDefault(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + Map params = new HashMap<>(); + params.put("node.shutdown.BlockTime", "0 0 0 L 4 ? 2027"); + params.put("storage.db.directory", "database"); + Config config = ConfigFactory.defaultOverrides() + .withFallback(ConfigFactory.parseMap(params)) + .withFallback(ConfigFactory.defaultReference()); + Args.applyConfigParams(config); + + CronExpression blockTime = Args.getInstance().getShutdownBlockTime(); + Assert.assertEquals("UTC", blockTime.getTimeZone().getID()); + Date lastDay = Date.from(Instant.parse("2027-04-30T00:00:00Z")); + Assert.assertEquals(lastDay, + blockTime.getNextValidTimeAfter(Date.from(Instant.parse("2027-04-01T00:00:00Z")))); + Assert.assertTrue(blockTime.isSatisfiedBy(lastDay)); + Assert.assertFalse(blockTime.isSatisfiedBy( + Date.from(Instant.parse("2027-04-29T16:00:00Z")))); + } finally { + TimeZone.setDefault(defaultZone); + } + } + @Test public void testInitService() { Map storage = new HashMap<>(); From 4e677ce2eb479ecec1fe01de264d032923e17d25 Mon Sep 17 00:00:00 2001 From: halibobo1205 Date: Wed, 4 Nov 2026 16:12:53 +0800 Subject: [PATCH 6/7] fix(db): improve BlockTime startup diagnostics Log up to three upcoming cron matches for node.shutdown.BlockTime at startup, in UTC with the weekday, counted from the head block time. They are candidates only: the node stops when a block timestamp exactly matches a cron time, which is not guaranteed. This helps an operator notice a misread expression but does not block it: "1O" is still read as 1. Treat NoSuchElementException from initAutoStop as AUTO_STOP_PARAMS. The accepted syntax is unchanged. --- .../main/java/org/tron/core/db/Manager.java | 41 ++- .../org/tron/core/db/ManagerAutoStopTest.java | 236 ++++++++++++++++++ 2 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 framework/src/test/java/org/tron/core/db/ManagerAutoStopTest.java diff --git a/framework/src/main/java/org/tron/core/db/Manager.java b/framework/src/main/java/org/tron/core/db/Manager.java index 9c0592f9fb3..ba633e8c80f 100644 --- a/framework/src/main/java/org/tron/core/db/Manager.java +++ b/framework/src/main/java/org/tron/core/db/Manager.java @@ -14,6 +14,8 @@ import com.google.common.primitives.Longs; import com.google.protobuf.ByteString; import io.prometheus.client.Histogram; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -24,7 +26,9 @@ import java.util.HashSet; import java.util.LinkedList; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.NoSuchElementException; import java.util.Objects; import java.util.Optional; import java.util.Set; @@ -186,6 +190,9 @@ public class Manager { private static final int TX_ID_CACHE_SIZE = 100_000; private static final int SLEEP_FOR_WAIT_LOCK = 10; private static final int NO_BLOCK_WAITING_LOCK = 0; + private static final int AUTO_STOP_PREVIEW_COUNT = 3; + private static final DateTimeFormatter AUTO_STOP_TIME_FORMAT = DateTimeFormatter + .ofPattern("EEE yyyy-MM-dd HH:mm:ss 'UTC'", Locale.US).withZone(ZoneOffset.UTC); private final int shieldedTransInPendingMaxCounts = Args.getInstance().getShieldedTransInPendingMaxCounts(); @Getter @@ -559,8 +566,8 @@ public void init() { // init auto-stop try { initAutoStop(); - } catch (IllegalArgumentException e) { - logger.error("Auto-stop params error: {}", e.getMessage()); + } catch (IllegalArgumentException | NoSuchElementException e) { + logger.error("Auto-stop params error: {}", e.toString()); throw new TronError(e, TronError.ErrCode.AUTO_STOP_PARAMS); } @@ -708,9 +715,16 @@ private void initAutoStop() { String.format("shutDownBlockCount %d is less than 1", exitCount)); } - if (blockTime != null && blockTime.getNextValidTimeAfter(new Date(headTime)) == null) { - throw new IllegalArgumentException( - String.format("shutDownBlockTime %s is illegal", blockTime)); + if (blockTime != null) { + List matches = nextCronMatches(blockTime, headTime); + if (matches.isEmpty()) { + throw new IllegalArgumentException( + String.format("shutDownBlockTime %s is illegal", blockTime)); + } + logger.info("Auto-stop: shutDownBlockTime {}, head block time {}, next cron matches {}. " + + "These are candidates: the node stops only if a block timestamp exactly matches " + + "one, and that is not guaranteed.", + blockTime, formatAutoStopTime(new Date(headTime)), matches); } if (exitHeight > 0 && exitCount > 0) { @@ -744,6 +758,23 @@ private void initAutoStop() { latestSolidityNumShutDown = CommonParameter.getInstance().getShutdownBlockHeight(); } + private List nextCronMatches(CronExpression cron, long headTime) { + List matches = new ArrayList<>(AUTO_STOP_PREVIEW_COUNT); + Date next = new Date(headTime); + for (int i = 0; i < AUTO_STOP_PREVIEW_COUNT; i++) { + next = cron.getNextValidTimeAfter(next); + if (next == null) { + break; + } + matches.add(formatAutoStopTime(next)); + } + return matches; + } + + private String formatAutoStopTime(Date time) { + return AUTO_STOP_TIME_FORMAT.format(time.toInstant()); + } + public AccountStore getAccountStore() { return chainBaseManager.getAccountStore(); } diff --git a/framework/src/test/java/org/tron/core/db/ManagerAutoStopTest.java b/framework/src/test/java/org/tron/core/db/ManagerAutoStopTest.java new file mode 100644 index 00000000000..d5c083c6bb5 --- /dev/null +++ b/framework/src/test/java/org/tron/core/db/ManagerAutoStopTest.java @@ -0,0 +1,236 @@ +package org.tron.core.db; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import java.text.ParseException; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Locale; +import java.util.NoSuchElementException; +import java.util.TimeZone; +import java.util.stream.Collectors; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.tron.common.TestConstants; +import org.tron.common.application.TronApplicationContext; +import org.tron.common.cron.CronExpression; +import org.tron.common.exit.ExitManager; +import org.tron.common.parameter.CommonParameter; +import org.tron.common.utils.ReflectUtils; +import org.tron.core.ChainBaseManager; +import org.tron.core.config.DefaultConfig; +import org.tron.core.config.args.Args; +import org.tron.core.exception.TronError; + +public class ManagerAutoStopTest { + + private static final String PREVIEW_PREFIX = "Auto-stop: shutDownBlockTime "; + private static final String CANDIDATE_NOTE = "These are candidates: the node stops only if " + + "a block timestamp exactly matches one, and that is not guaranteed."; + private static final long HEAD_TIME = Instant.parse("2026-06-01T00:00:00Z").toEpochMilli(); + private static final String HEAD_TEXT = "Mon 2026-06-01 00:00:00 UTC"; + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private final Logger dbLogger = (Logger) LoggerFactory.getLogger("DB"); + private final ListAppender dbLog = new ListAppender<>(); + private Manager startupManager; + + @Before + public void attachLog() { + dbLog.start(); + dbLogger.addAppender(dbLog); + } + + @After + public void cleanUp() { + dbLogger.detachAppender(dbLog); + Mockito.framework().clearInlineMocks(); + if (startupManager != null) { + startupManager.close(); + } + Args.clearParam(); + } + + @Test + public void singleFutureMatchIsPreviewedAndNotRejected() throws Exception { + CronExpression cron = utcCron("0 0 12 15 6 ? 2027"); + + runInitAutoStop(HEAD_TIME, 0L, cron); + + assertEquals(Collections.singletonList(preview("0 0 12 15 6 ? 2027", HEAD_TEXT, + "Tue 2027-06-15 12:00:00 UTC")), previews()); + verify(cron, times(2)).getNextValidTimeAfter(any(Date.class)); + } + + @Test + public void twoFutureMatchesEndThePreviewAtNull() throws Exception { + CronExpression cron = utcCron("0 0 12 15 6 ? 2027-2028"); + + runInitAutoStop(HEAD_TIME, 0L, cron); + + assertEquals(Collections.singletonList(preview("0 0 12 15 6 ? 2027-2028", HEAD_TEXT, + "Tue 2027-06-15 12:00:00 UTC", "Thu 2028-06-15 12:00:00 UTC")), previews()); + verify(cron, times(3)).getNextValidTimeAfter(any(Date.class)); + } + + @Test + public void thirdMatchEndsThePreviewWithoutAFourthQuery() throws Exception { + CronExpression cron = utcCron("0 0 12 15 6 ? 2027-2029"); + + runInitAutoStop(HEAD_TIME, 0L, cron); + + assertEquals(Collections.singletonList(preview("0 0 12 15 6 ? 2027-2029", HEAD_TEXT, + "Tue 2027-06-15 12:00:00 UTC", "Thu 2028-06-15 12:00:00 UTC", + "Fri 2029-06-15 12:00:00 UTC")), previews()); + verify(cron, times(3)).getNextValidTimeAfter(any(Date.class)); + } + + @Test + public void moreThanThreeFutureMatchesPreviewOnlyThree() throws Exception { + CronExpression cron = utcCron("0 0 12 * * ?"); + + runInitAutoStop(HEAD_TIME, 0L, cron); + + assertEquals(Collections.singletonList(preview("0 0 12 * * ?", HEAD_TEXT, + "Mon 2026-06-01 12:00:00 UTC", "Tue 2026-06-02 12:00:00 UTC", + "Wed 2026-06-03 12:00:00 UTC")), previews()); + verify(cron, times(3)).getNextValidTimeAfter(any(Date.class)); + } + + @Test + public void noFutureMatchKeepsTheStartupRejection() throws Exception { + CronExpression cron = utcCron("0 0 12 1 1 ? 2020"); + long head = Instant.parse("2021-01-01T00:00:00Z").toEpochMilli(); + + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> runInitAutoStop(head, 0L, cron)); + + assertEquals("shutDownBlockTime 0 0 12 1 1 ? 2020 is illegal", thrown.getMessage()); + assertTrue(previews().isEmpty()); + verify(cron, times(1)).getNextValidTimeAfter(any(Date.class)); + } + + @Test + public void previewStartsFromTheHeadBlockTime() throws Exception { + CronExpression cron = utcCron("0 0 12 1 1 ? 2001"); + long head = Instant.parse("2000-01-01T00:00:00Z").toEpochMilli(); + + runInitAutoStop(head, 0L, cron); + + assertEquals(Collections.singletonList(preview("0 0 12 1 1 ? 2001", + "Sat 2000-01-01 00:00:00 UTC", "Mon 2001-01-01 12:00:00 UTC")), previews()); + verify(cron).getNextValidTimeAfter(new Date(head)); + } + + @Test + public void previewIgnoresTheDefaultZoneAndLocale() throws Exception { + TimeZone defaultZone = TimeZone.getDefault(); + Locale defaultLocale = Locale.getDefault(); + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai")); + Locale.setDefault(new Locale("th", "TH")); + try { + runInitAutoStop(HEAD_TIME, 0L, utcCron("0 0 12 15 6 ? 2027")); + } finally { + TimeZone.setDefault(defaultZone); + Locale.setDefault(defaultLocale); + } + + assertEquals(Collections.singletonList(preview("0 0 12 15 6 ? 2027", HEAD_TEXT, + "Tue 2027-06-15 12:00:00 UTC")), previews()); + } + + @Test + public void withoutBlockTimeNothingIsPreviewed() throws Exception { + Manager dbManager = runInitAutoStop(HEAD_TIME, 100L, null); + + assertTrue(previews().isEmpty()); + assertEquals(100L, (long) ReflectUtils.getFieldValue(dbManager, "latestSolidityNumShutDown")); + } + + @Test + public void cronRuntimeFailureStopsStartupWithAutoStopParams() throws Exception { + Args.setParam(new String[] {"--output-directory", temporaryFolder.newFolder().toString()}, + TestConstants.TEST_CONF); + Args.getInstance().setShutdownBlockTime(new CronExpression("0 0 0 1 L ?")); + TronApplicationContext context = new TronApplicationContext(); + context.getDefaultListableBeanFactory().addBeanPostProcessor(new BeanPostProcessor() { + @Override + public Object postProcessBeforeInitialization(Object bean, String beanName) { + if (bean instanceof Manager) { + startupManager = (Manager) bean; + } + return bean; + } + }); + context.register(DefaultConfig.class); + + BeansException thrown = assertThrows(BeansException.class, context::refresh); + + TronError error = ExitManager.findTronError(thrown).orElseThrow(AssertionError::new); + assertEquals(TronError.ErrCode.AUTO_STOP_PARAMS, error.getErrCode()); + assertEquals(NoSuchElementException.class, error.getCause().getClass()); + assertTrue(dbLog.list.stream().map(ILoggingEvent::getFormattedMessage).anyMatch( + "Auto-stop params error: java.util.NoSuchElementException"::equals)); + } + + private CronExpression utcCron(String expression) throws ParseException { + CronExpression cron = new CronExpression(expression); + cron.setTimeZone(TimeZone.getTimeZone(ZoneOffset.UTC)); + return spy(cron); + } + + private Manager runInitAutoStop(long headTime, long exitHeight, CronExpression blockTime) { + ChainBaseManager chainBaseManager = mock(ChainBaseManager.class); + when(chainBaseManager.getHeadBlockNum()).thenReturn(10L); + when(chainBaseManager.getHeadBlockTimeStamp()).thenReturn(headTime); + Manager dbManager = new Manager(); + ReflectUtils.setFieldValue(dbManager, "chainBaseManager", chainBaseManager); + CommonParameter parameter = new CommonParameter(); + parameter.setShutdownBlockHeight(exitHeight); + parameter.setShutdownBlockCount(-1L); + parameter.setShutdownBlockTime(blockTime); + try (MockedStatic ignored = mockStatic(CommonParameter.class)) { + when(CommonParameter.getInstance()).thenReturn(parameter); + ReflectUtils.invokeMethod(dbManager, "initAutoStop"); + } + return dbManager; + } + + private List previews() { + return dbLog.list.stream() + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.startsWith(PREVIEW_PREFIX)) + .collect(Collectors.toList()); + } + + private static String preview(String expression, String head, String... matches) { + return PREVIEW_PREFIX + expression + ", head block time " + head + ", next cron matches " + + Arrays.toString(matches) + ". " + CANDIDATE_NOTE; + } +} From ee69633067fe0cc8d35173a0d291c8888c58998c Mon Sep 17 00:00:00 2001 From: halibobo1205 Date: Wed, 4 Nov 2026 19:38:55 +0800 Subject: [PATCH 7/7] fix(net): check auto-stop under the block lock processBlock checked the stop height before taking blockLock, so on the Netty path a block could pass the check and then wait for the lock. If the block ahead of it persisted the target height, the waiting block moved the persisted height past the target: the exact-match check then never held, and the node did not stop. A waiting block could also advance the persisted height after the stop signal. Run the check under blockLock, right before the block is pushed. The exact match is unchanged. The new tests force this overlap on a real Manager; they do not measure how often it happens on mainnet. --- .../org/tron/core/net/TronNetDelegate.java | 30 ++--- .../services/stop/BlockStopOverlapTest.java | 108 ++++++++++++++++++ .../services/stop/ConditionallyStopTest.java | 6 +- 3 files changed, 126 insertions(+), 18 deletions(-) create mode 100644 framework/src/test/java/org/tron/core/services/stop/BlockStopOverlapTest.java diff --git a/framework/src/main/java/org/tron/core/net/TronNetDelegate.java b/framework/src/main/java/org/tron/core/net/TronNetDelegate.java index 2948862b181..8744cf9840f 100644 --- a/framework/src/main/java/org/tron/core/net/TronNetDelegate.java +++ b/framework/src/main/java/org/tron/core/net/TronNetDelegate.java @@ -251,23 +251,23 @@ public void pushVerifiedBlock(BlockCapsule block) throws P2pException { } public void processBlock(BlockCapsule block, boolean isSync) throws P2pException { - if (!hitDown && dbManager.getLatestSolidityNumShutDown() > 0 - && dbManager.getLatestSolidityNumShutDown() == dbManager.getDynamicPropertiesStore() - .getLatestBlockHeaderNumberFromDB()) { - - logger.info("Begin shutdown, currentBlockNum:{}, DbBlockNum:{}, solidifiedBlockNum:{}", - dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumber(), - dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumberFromDB(), - dbManager.getDynamicPropertiesStore().getLatestSolidifiedBlockNum()); - hitDown = true; - LockSupport.unpark(hitThread); - return; - } - if (hitDown) { - return; - } BlockId blockId = block.getBlockId(); synchronized (blockLock) { + if (!hitDown && dbManager.getLatestSolidityNumShutDown() > 0 + && dbManager.getLatestSolidityNumShutDown() == dbManager.getDynamicPropertiesStore() + .getLatestBlockHeaderNumberFromDB()) { + + logger.info("Begin shutdown, currentBlockNum:{}, DbBlockNum:{}, solidifiedBlockNum:{}", + dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumber(), + dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumberFromDB(), + dbManager.getDynamicPropertiesStore().getLatestSolidifiedBlockNum()); + hitDown = true; + LockSupport.unpark(hitThread); + return; + } + if (hitDown) { + return; + } try { if (freshBlockId.getIfPresent(blockId) == null) { if (block.getNum() <= getHeadBlockId().getNum()) { diff --git a/framework/src/test/java/org/tron/core/services/stop/BlockStopOverlapTest.java b/framework/src/test/java/org/tron/core/services/stop/BlockStopOverlapTest.java new file mode 100644 index 00000000000..6f5b949ba1b --- /dev/null +++ b/framework/src/test/java/org/tron/core/services/stop/BlockStopOverlapTest.java @@ -0,0 +1,108 @@ +package org.tron.core.services.stop; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.Test; +import org.tron.common.parameter.CommonParameter; +import org.tron.core.capsule.BlockCapsule; + +public class BlockStopOverlapTest extends ConditionallyStopTest { + + private static final long HEIGHT = 5; + + private final AtomicReference waitingError = new AtomicReference<>(); + + @Override + protected void initParameter(CommonParameter parameter) { + parameter.setShutdownBlockHeight(HEIGHT); + parameter.getStorage().setMaxFlushCount(1); + } + + @Override + protected void check() { + assertEquals(HEIGHT, persistedHeight()); + } + + @Test(timeout = 30_000) + public void blockWaitingForTheLockStopsAtTheTargetHeight() throws Exception { + BlockCapsule first = nextBlockBeforeTargetIsPersisted(); + BlockCapsule second = blockAfter(first); + Thread waiting; + + synchronized (tronNetDelegate.getBlockLock()) { + waiting = startWaitingForTheLock(second); + tronNetDelegate.processBlock(first, false); + assertEquals(HEIGHT, persistedHeight()); + } + waiting.join(); + + assertNull(waitingError.get()); + assertTrue(tronNetDelegate.isHitDown()); + assertStoppedAfter(first); + } + + @Test(timeout = 30_000) + public void blockWaitingForTheLockIsDroppedAfterTheStopSignal() throws Exception { + BlockCapsule first = nextBlockBeforeTargetIsPersisted(); + BlockCapsule second = blockAfter(first); + Thread waiting; + + synchronized (tronNetDelegate.getBlockLock()) { + waiting = startWaitingForTheLock(second); + tronNetDelegate.processBlock(first, false); + tronNetDelegate.processBlock(second, false); + assertTrue(tronNetDelegate.isHitDown()); + } + waiting.join(); + + assertNull(waitingError.get()); + assertStoppedAfter(first); + } + + private BlockCapsule nextBlockBeforeTargetIsPersisted() throws Exception { + while (persistedHeight() < HEIGHT - 1) { + generateBlock(); + } + return createTestBlockCapsule( + chainManager.getNextBlockSlotTime(), + headHeight() + 1, + chainManager.getDynamicPropertiesStore().getLatestBlockHeaderHash().getByteString()); + } + + private BlockCapsule blockAfter(BlockCapsule block) { + return createTestBlockCapsule(block.getTimeStamp() + 3000, block.getNum() + 1, + block.getBlockId().getByteString()); + } + + private Thread startWaitingForTheLock(BlockCapsule block) throws InterruptedException { + Thread thread = new Thread(() -> { + try { + tronNetDelegate.processBlock(block, false); + } catch (Throwable t) { + waitingError.set(t); + } + }, "waiting-block"); + thread.start(); + while (thread.getState() != Thread.State.BLOCKED) { + assertTrue(thread.isAlive()); + Thread.sleep(1); + } + return thread; + } + + private void assertStoppedAfter(BlockCapsule block) { + assertEquals(HEIGHT, persistedHeight()); + assertEquals(block.getNum(), headHeight()); + } + + private long persistedHeight() { + return dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumberFromDB(); + } + + private long headHeight() { + return dbManager.getDynamicPropertiesStore().getLatestBlockHeaderNumber(); + } +} diff --git a/framework/src/test/java/org/tron/core/services/stop/ConditionallyStopTest.java b/framework/src/test/java/org/tron/core/services/stop/ConditionallyStopTest.java index 964cfbf254d..45a22a35621 100644 --- a/framework/src/test/java/org/tron/core/services/stop/ConditionallyStopTest.java +++ b/framework/src/test/java/org/tron/core/services/stop/ConditionallyStopTest.java @@ -38,7 +38,7 @@ public abstract class ConditionallyStopTest extends BaseMethodTest { private static DposSlot dposSlot; private final AtomicInteger port = new AtomicInteger(0); long currentHeader = -1; - private TronNetDelegate tronNetDelegate; + protected TronNetDelegate tronNetDelegate; private DposService dposService; private ConsensusDelegate consensusDelegate; @@ -90,7 +90,7 @@ protected void afterInit() { chainManager.getDynamicPropertiesStore().saveNextMaintenanceTime(time); } - private void generateBlock() throws Exception { + protected void generateBlock() throws Exception { BlockCapsule block = createTestBlockCapsule( @@ -132,7 +132,7 @@ private Map addTestWitnessAndAccount() { .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } - private BlockCapsule createTestBlockCapsule(long time, + protected BlockCapsule createTestBlockCapsule(long time, long number, ByteString hash) { long slot = dposSlot.getSlot(time); ByteString witness = dposSlot.getScheduledWitness(slot);