@@ -1643,37 +1643,183 @@ describe("startGatewayConfigReloader watcher error recovery", () => {
16431643 await reloader . stop ( ) ;
16441644 } ) ;
16451645
1646- it ( "disables hot-reload and logs at error level after the retry budget is exhausted" , async ( ) => {
1647- const watchers = [
1648- createWatcherMock ( ) ,
1649- createWatcherMock ( ) ,
1650- createWatcherMock ( ) ,
1651- createWatcherMock ( ) ,
1652- ] ;
1653- const { watchSpy, log, reloader } = startReloaderWithWatchers ( watchers ) ;
1654-
1655- // Three errors consume the retry budget; the fourth error escalates.
1656- watchers [ 0 ] ?. emit ( "error" ) ;
1657- await vi . advanceTimersByTimeAsync ( 500 ) ;
1658- watchers [ 1 ] ?. emit ( "error" ) ;
1659- await vi . advanceTimersByTimeAsync ( 2000 ) ;
1660- watchers [ 2 ] ?. emit ( "error" ) ;
1661- await vi . advanceTimersByTimeAsync ( 5000 ) ;
1662- expect ( watchSpy ) . toHaveBeenCalledTimes ( 4 ) ;
1663- expect ( reloader . hotReloadStatus ( ) ) . toBe ( "active" ) ;
1646+ it ( "degrades to polling then disables after both native and polling retries are exhausted" , async ( ) => {
1647+ const originalVitest = process . env . VITEST ;
1648+ const originalChokidarPolling = process . env . CHOKIDAR_USEPOLLING ;
1649+ delete process . env . VITEST ;
1650+ delete process . env . CHOKIDAR_USEPOLLING ;
1651+ let reloader : { stop : ( ) => Promise < void > ; hotReloadStatus : ( ) => string } | undefined ;
1652+ try {
1653+ // Native phase: initial watcher + 3 re-creates = 4 watchers.
1654+ // Polling phase: 1 polling re-create + 3 re-creates = 4 watchers.
1655+ const watchers = Array . from ( { length : 8 } , ( ) => createWatcherMock ( ) ) ;
1656+ const started = startReloaderWithWatchers ( watchers ) ;
1657+ const { watchSpy, log } = started ;
1658+ reloader = started . reloader ;
1659+ const watchOptions = ( index : number ) =>
1660+ watchSpy . mock . calls [ index ] ?. [ 1 ] as { usePolling ?: boolean } | undefined ;
1661+
1662+ // --- Native retry phase (3 retries) ---
1663+ expect ( watchOptions ( 0 ) ?. usePolling ) . toBe ( false ) ;
1664+ watchers [ 0 ] ?. emit ( "error" ) ;
1665+ await vi . advanceTimersByTimeAsync ( 500 ) ;
1666+ expect ( watchOptions ( 1 ) ?. usePolling ) . toBe ( false ) ;
1667+ watchers [ 1 ] ?. emit ( "error" ) ;
1668+ await vi . advanceTimersByTimeAsync ( 2000 ) ;
1669+ expect ( watchOptions ( 2 ) ?. usePolling ) . toBe ( false ) ;
1670+ watchers [ 2 ] ?. emit ( "error" ) ;
1671+ await vi . advanceTimersByTimeAsync ( 5000 ) ;
1672+ expect ( watchSpy ) . toHaveBeenCalledTimes ( 4 ) ;
1673+ expect ( watchOptions ( 3 ) ?. usePolling ) . toBe ( false ) ;
1674+ expect ( reloader . hotReloadStatus ( ) ) . toBe ( "active" ) ;
1675+
1676+ // Fourth native error triggers degradation to polling mode (not disabled).
1677+ watchers [ 3 ] ?. emit ( "error" ) ;
1678+ expect ( reloader . hotReloadStatus ( ) ) . toBe ( "active" ) ;
1679+ expect ( log . warn ) . toHaveBeenCalledWith (
1680+ expect . stringContaining ( "degrading to polling mode" ) ,
1681+ ) ;
1682+ await vi . advanceTimersByTimeAsync ( 500 ) ;
1683+ expect ( watchSpy ) . toHaveBeenCalledTimes ( 5 ) ;
1684+ expect ( watchOptions ( 4 ) ?. usePolling ) . toBe ( true ) ;
1685+
1686+ // --- Polling retry phase (3 retries) ---
1687+ watchers [ 4 ] ?. emit ( "error" ) ;
1688+ await vi . advanceTimersByTimeAsync ( 500 ) ;
1689+ expect ( watchOptions ( 5 ) ?. usePolling ) . toBe ( true ) ;
1690+ watchers [ 5 ] ?. emit ( "error" ) ;
1691+ await vi . advanceTimersByTimeAsync ( 2000 ) ;
1692+ expect ( watchOptions ( 6 ) ?. usePolling ) . toBe ( true ) ;
1693+ watchers [ 6 ] ?. emit ( "error" ) ;
1694+ await vi . advanceTimersByTimeAsync ( 5000 ) ;
1695+ expect ( watchSpy ) . toHaveBeenCalledTimes ( 8 ) ;
1696+ expect ( watchOptions ( 7 ) ?. usePolling ) . toBe ( true ) ;
1697+ expect ( reloader . hotReloadStatus ( ) ) . toBe ( "active" ) ;
1698+
1699+ // Eighth error in polling mode finally disables hot-reload.
1700+ watchers [ 7 ] ?. emit ( "error" ) ;
1701+ expect ( reloader . hotReloadStatus ( ) ) . toBe ( "disabled" ) ;
1702+ expect ( log . error ) . toHaveBeenCalledWith (
1703+ expect . stringContaining (
1704+ "config hot-reload disabled: watcher failed after 3 re-create attempts in polling mode" ,
1705+ ) ,
1706+ ) ;
1707+ // No further watcher is created once disabled.
1708+ await vi . advanceTimersByTimeAsync ( 10000 ) ;
1709+ expect ( watchSpy ) . toHaveBeenCalledTimes ( 8 ) ;
1710+ } finally {
1711+ if ( originalVitest === undefined ) {
1712+ delete process . env . VITEST ;
1713+ } else {
1714+ process . env . VITEST = originalVitest ;
1715+ }
1716+ if ( originalChokidarPolling === undefined ) {
1717+ delete process . env . CHOKIDAR_USEPOLLING ;
1718+ } else {
1719+ process . env . CHOKIDAR_USEPOLLING = originalChokidarPolling ;
1720+ }
1721+ await reloader ?. stop ( ) ;
1722+ }
1723+ } ) ;
16641724
1665- watchers [ 3 ] ?. emit ( "error" ) ;
1666- expect ( reloader . hotReloadStatus ( ) ) . toBe ( "disabled" ) ;
1667- expect ( log . error ) . toHaveBeenCalledWith (
1668- expect . stringContaining (
1669- "config hot-reload disabled: watcher failed after 3 re-create attempts" ,
1670- ) ,
1671- ) ;
1672- // No further watcher is created once disabled.
1673- await vi . advanceTimersByTimeAsync ( 10000 ) ;
1674- expect ( watchSpy ) . toHaveBeenCalledTimes ( 4 ) ;
1725+ it ( "does not run a redundant native phase when chokidar polling is forced on" , async ( ) => {
1726+ const originalVitest = process . env . VITEST ;
1727+ const originalChokidarPolling = process . env . CHOKIDAR_USEPOLLING ;
1728+ delete process . env . VITEST ;
1729+ process . env . CHOKIDAR_USEPOLLING = "1" ;
1730+ let reloader : { stop : ( ) => Promise < void > ; hotReloadStatus : ( ) => string } | undefined ;
1731+ try {
1732+ const watchers = Array . from ( { length : 4 } , ( ) => createWatcherMock ( ) ) ;
1733+ const started = startReloaderWithWatchers ( watchers ) ;
1734+ const { watchSpy, log } = started ;
1735+ reloader = started . reloader ;
1736+ const watchOptions = ( index : number ) =>
1737+ watchSpy . mock . calls [ index ] ?. [ 1 ] as { usePolling ?: boolean } | undefined ;
1738+
1739+ expect ( watchOptions ( 0 ) ?. usePolling ) . toBe ( true ) ;
1740+ watchers [ 0 ] ?. emit ( "error" ) ;
1741+ await vi . advanceTimersByTimeAsync ( 500 ) ;
1742+ expect ( watchOptions ( 1 ) ?. usePolling ) . toBe ( true ) ;
1743+ watchers [ 1 ] ?. emit ( "error" ) ;
1744+ await vi . advanceTimersByTimeAsync ( 2000 ) ;
1745+ expect ( watchOptions ( 2 ) ?. usePolling ) . toBe ( true ) ;
1746+ watchers [ 2 ] ?. emit ( "error" ) ;
1747+ await vi . advanceTimersByTimeAsync ( 5000 ) ;
1748+ expect ( watchOptions ( 3 ) ?. usePolling ) . toBe ( true ) ;
1749+
1750+ watchers [ 3 ] ?. emit ( "error" ) ;
1751+ expect ( reloader . hotReloadStatus ( ) ) . toBe ( "disabled" ) ;
1752+ expect ( log . warn ) . not . toHaveBeenCalledWith (
1753+ expect . stringContaining ( "degrading to polling mode" ) ,
1754+ ) ;
1755+ expect ( log . error ) . toHaveBeenCalledWith (
1756+ expect . stringContaining (
1757+ "config hot-reload disabled: watcher failed after 3 re-create attempts in polling mode" ,
1758+ ) ,
1759+ ) ;
1760+ } finally {
1761+ if ( originalVitest === undefined ) {
1762+ delete process . env . VITEST ;
1763+ } else {
1764+ process . env . VITEST = originalVitest ;
1765+ }
1766+ if ( originalChokidarPolling === undefined ) {
1767+ delete process . env . CHOKIDAR_USEPOLLING ;
1768+ } else {
1769+ process . env . CHOKIDAR_USEPOLLING = originalChokidarPolling ;
1770+ }
1771+ await reloader ?. stop ( ) ;
1772+ }
1773+ } ) ;
16751774
1676- await reloader . stop ( ) ;
1775+ it ( "does not report polling fallback when chokidar polling is forced off" , async ( ) => {
1776+ const originalVitest = process . env . VITEST ;
1777+ const originalChokidarPolling = process . env . CHOKIDAR_USEPOLLING ;
1778+ delete process . env . VITEST ;
1779+ process . env . CHOKIDAR_USEPOLLING = "0" ;
1780+ let reloader : { stop : ( ) => Promise < void > ; hotReloadStatus : ( ) => string } | undefined ;
1781+ try {
1782+ const watchers = Array . from ( { length : 4 } , ( ) => createWatcherMock ( ) ) ;
1783+ const started = startReloaderWithWatchers ( watchers ) ;
1784+ const { watchSpy, log } = started ;
1785+ reloader = started . reloader ;
1786+ const watchOptions = ( index : number ) =>
1787+ watchSpy . mock . calls [ index ] ?. [ 1 ] as { usePolling ?: boolean } | undefined ;
1788+
1789+ expect ( watchOptions ( 0 ) ?. usePolling ) . toBe ( false ) ;
1790+ watchers [ 0 ] ?. emit ( "error" ) ;
1791+ await vi . advanceTimersByTimeAsync ( 500 ) ;
1792+ expect ( watchOptions ( 1 ) ?. usePolling ) . toBe ( false ) ;
1793+ watchers [ 1 ] ?. emit ( "error" ) ;
1794+ await vi . advanceTimersByTimeAsync ( 2000 ) ;
1795+ expect ( watchOptions ( 2 ) ?. usePolling ) . toBe ( false ) ;
1796+ watchers [ 2 ] ?. emit ( "error" ) ;
1797+ await vi . advanceTimersByTimeAsync ( 5000 ) ;
1798+ expect ( watchOptions ( 3 ) ?. usePolling ) . toBe ( false ) ;
1799+
1800+ watchers [ 3 ] ?. emit ( "error" ) ;
1801+ expect ( reloader . hotReloadStatus ( ) ) . toBe ( "disabled" ) ;
1802+ expect ( log . warn ) . not . toHaveBeenCalledWith (
1803+ expect . stringContaining ( "degrading to polling mode" ) ,
1804+ ) ;
1805+ expect ( log . error ) . toHaveBeenCalledWith (
1806+ expect . stringContaining (
1807+ "config hot-reload disabled: watcher failed after 3 re-create attempts in native mode" ,
1808+ ) ,
1809+ ) ;
1810+ } finally {
1811+ if ( originalVitest === undefined ) {
1812+ delete process . env . VITEST ;
1813+ } else {
1814+ process . env . VITEST = originalVitest ;
1815+ }
1816+ if ( originalChokidarPolling === undefined ) {
1817+ delete process . env . CHOKIDAR_USEPOLLING ;
1818+ } else {
1819+ process . env . CHOKIDAR_USEPOLLING = originalChokidarPolling ;
1820+ }
1821+ await reloader ?. stop ( ) ;
1822+ }
16771823 } ) ;
16781824} ) ;
16791825
0 commit comments