@@ -313,6 +313,20 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i;
313313
314314const pluginName = "webpack-dev-server" ;
315315
316+ /**
317+ * Tracks compilers that have an active standalone `Server` attached
318+ * (`new Server(options, compiler).start()`). When a compiler is in this set,
319+ * any `Server` plugin attached to it (or to a `MultiCompiler` that contains
320+ * it) stays passive — otherwise we'd try to bind the same port twice.
321+ * Particularly relevant for `webpack serve`, which creates its own standalone
322+ * server even when the user already added a `Server` instance to `plugins[]`.
323+ *
324+ * Uses a `WeakSet` so a compiler that is no longer referenced anywhere can be
325+ * garbage collected normally, without this module holding it alive.
326+ * @type {WeakSet<Compiler | MultiCompiler> }
327+ */
328+ const activeStandaloneCompilers = new WeakSet ( ) ;
329+
316330/**
317331 * @template {BasicApplication} [A=ExpressApplication]
318332 * @template {BasicServer} [S=HTTPServer]
@@ -1340,7 +1354,12 @@ class Server {
13401354 }
13411355
13421356 if ( typeof options . setupExitSignals === "undefined" ) {
1343- options . setupExitSignals = true ;
1357+ // In plugin mode, the host (e.g. `webpack-cli`) usually owns process
1358+ // signal handling and calls `compiler.close()` on shutdown, which fires
1359+ // our `shutdown` hook. Adding our own SIGINT/SIGTERM listeners on top of
1360+ // that would race with the host's handler and call `compiler.close()`
1361+ // twice.
1362+ options . setupExitSignals = ! this . isPlugin ;
13441363 }
13451364
13461365 if ( typeof options . static === "undefined" ) {
@@ -3252,8 +3271,36 @@ class Server {
32523271 * @returns {Promise<void> }
32533272 */
32543273 async start ( ) {
3255- await this . setup ( ) ;
3256- await this . listen ( ) ;
3274+ this . #trackStandalone( true ) ;
3275+ try {
3276+ await this . setup ( ) ;
3277+ await this . listen ( ) ;
3278+ } catch ( error ) {
3279+ this . #trackStandalone( false ) ;
3280+ throw error ;
3281+ }
3282+ }
3283+
3284+ /**
3285+ * @param {boolean } active whether to mark or unmark the compiler(s) as
3286+ * having an active standalone server
3287+ * @returns {void }
3288+ */
3289+ #trackStandalone( active ) {
3290+ if ( ! this . compiler ) return ;
3291+ const compilers = /** @type {MultiCompiler } */ ( this . compiler )
3292+ . compilers || [ this . compiler ] ;
3293+ if ( active ) {
3294+ activeStandaloneCompilers . add ( this . compiler ) ;
3295+ for ( const child of compilers ) {
3296+ activeStandaloneCompilers . add ( child ) ;
3297+ }
3298+ } else {
3299+ activeStandaloneCompilers . delete ( this . compiler ) ;
3300+ for ( const child of compilers ) {
3301+ activeStandaloneCompilers . delete ( child ) ;
3302+ }
3303+ }
32573304 }
32583305
32593306 /**
@@ -3370,6 +3417,8 @@ class Server {
33703417 * @returns {Promise<void> }
33713418 */
33723419 async stop ( ) {
3420+ this . #trackStandalone( false ) ;
3421+
33733422 if ( this . bonjour ) {
33743423 await /** @type {Promise<void> } */ (
33753424 new Promise ( ( resolve ) => {
@@ -3474,27 +3523,51 @@ class Server {
34743523 let listening = false ;
34753524 let stopped = false ;
34763525
3526+ const childCompilers = /** @type {MultiCompiler } */ ( compiler )
3527+ . compilers || [ compiler ] ;
3528+ const seenFirstDone = new WeakSet ( ) ;
3529+ let firstDoneCount = 0 ;
3530+
3531+ // Returns true when a standalone `Server` is already attached to our
3532+ // compiler (or any of its children). This matters for `webpack serve`:
3533+ // it creates its own standalone server even if the user added a `Server`
3534+ // instance to `plugins[]`. In that case the plugin must stay passive —
3535+ // otherwise we'd try to bind the same port twice. Independent
3536+ // server/compiler pairs in the same process are unaffected because they
3537+ // don't share any compiler instance.
3538+ const isStandaloneRunning = ( ) => {
3539+ if ( activeStandaloneCompilers . has ( compiler ) ) return true ;
3540+ for ( const child of childCompilers ) {
3541+ if ( activeStandaloneCompilers . has ( child ) ) return true ;
3542+ }
3543+ return false ;
3544+ } ;
3545+
3546+ // A one-shot `compiler.run()` (plain `webpack` build) is detected when no
3547+ // child compiler is in watch mode. In that case we skip both `setup()` and
3548+ // `listen()` so the build can finish and the process can exit normally —
3549+ // the user is not in control of the plugin lifecycle here, so we stay
3550+ // silent rather than logging a warning.
3551+ const isBuildMode = ( ) =>
3552+ childCompilers . every ( ( child ) => ! child . watching && ! child . options . watch ) ;
3553+
34773554 /**
34783555 * @returns {Promise<void> } promise
34793556 */
34803557 const ensureSetup = ( ) => {
3558+ if ( isStandaloneRunning ( ) || isBuildMode ( ) ) return Promise . resolve ( ) ;
34813559 if ( ! setupPromise ) {
34823560 setupPromise = this . setup ( ) ;
34833561 }
34843562 return setupPromise ;
34853563 } ;
34863564
3487- const childCompilers = /** @type {MultiCompiler } */ ( compiler )
3488- . compilers || [ compiler ] ;
3489- const seenFirstDone = new WeakSet ( ) ;
3490- let firstDoneCount = 0 ;
3491-
34923565 /**
34933566 * @param {Compiler } childCompiler child compiler
34943567 * @returns {Promise<void> } promise
34953568 */
34963569 const onChildDone = async ( childCompiler ) => {
3497- if ( listening ) return ;
3570+ if ( listening || isStandaloneRunning ( ) || isBuildMode ( ) ) return ;
34983571 if ( seenFirstDone . has ( childCompiler ) ) return ;
34993572 seenFirstDone . add ( childCompiler ) ;
35003573 firstDoneCount ++ ;
0 commit comments