@@ -328,6 +328,20 @@ const DEFAULT_ALLOWED_PROTOCOLS = /^(file|.+-extension):/i;
328328
329329const pluginName = "webpack-dev-server" ;
330330
331+ /**
332+ * Tracks compilers that have an active standalone `Server` attached
333+ * (`new Server(options, compiler).start()`). When a compiler is in this set,
334+ * any `Server` plugin attached to it (or to a `MultiCompiler` that contains
335+ * it) stays passive — otherwise we'd try to bind the same port twice.
336+ * Particularly relevant for `webpack serve`, which creates its own standalone
337+ * server even when the user already added a `Server` instance to `plugins[]`.
338+ *
339+ * Uses a `WeakSet` so a compiler that is no longer referenced anywhere can be
340+ * garbage collected normally, without this module holding it alive.
341+ * @type {WeakSet<Compiler | MultiCompiler> }
342+ */
343+ const activeStandaloneCompilers = new WeakSet ( ) ;
344+
331345/**
332346 * @template {BasicApplication} [A=ExpressApplication]
333347 * @template {BasicServer} [S=HTTPServer]
@@ -1359,7 +1373,12 @@ class Server {
13591373 }
13601374
13611375 if ( typeof options . setupExitSignals === "undefined" ) {
1362- options . setupExitSignals = true ;
1376+ // In plugin mode, the host (e.g. `webpack-cli`) usually owns process
1377+ // signal handling and calls `compiler.close()` on shutdown, which fires
1378+ // our `shutdown` hook. Adding our own SIGINT/SIGTERM listeners on top of
1379+ // that would race with the host's handler and call `compiler.close()`
1380+ // twice.
1381+ options . setupExitSignals = ! this . isPlugin ;
13631382 }
13641383
13651384 if ( typeof options . static === "undefined" ) {
@@ -3355,8 +3374,36 @@ class Server {
33553374 * @returns {Promise<void> }
33563375 */
33573376 async start ( ) {
3358- await this . setup ( ) ;
3359- await this . listen ( ) ;
3377+ this . #trackStandalone( true ) ;
3378+ try {
3379+ await this . setup ( ) ;
3380+ await this . listen ( ) ;
3381+ } catch ( error ) {
3382+ this . #trackStandalone( false ) ;
3383+ throw error ;
3384+ }
3385+ }
3386+
3387+ /**
3388+ * @param {boolean } active whether to mark or unmark the compiler(s) as
3389+ * having an active standalone server
3390+ * @returns {void }
3391+ */
3392+ #trackStandalone( active ) {
3393+ if ( ! this . compiler ) return ;
3394+ const compilers = /** @type {MultiCompiler } */ ( this . compiler )
3395+ . compilers || [ this . compiler ] ;
3396+ if ( active ) {
3397+ activeStandaloneCompilers . add ( this . compiler ) ;
3398+ for ( const child of compilers ) {
3399+ activeStandaloneCompilers . add ( child ) ;
3400+ }
3401+ } else {
3402+ activeStandaloneCompilers . delete ( this . compiler ) ;
3403+ for ( const child of compilers ) {
3404+ activeStandaloneCompilers . delete ( child ) ;
3405+ }
3406+ }
33603407 }
33613408
33623409 /**
@@ -3473,6 +3520,8 @@ class Server {
34733520 * @returns {Promise<void> }
34743521 */
34753522 async stop ( ) {
3523+ this . #trackStandalone( false ) ;
3524+
34763525 if ( this . bonjour ) {
34773526 await /** @type {Promise<void> } */ (
34783527 new Promise ( ( resolve ) => {
@@ -3588,27 +3637,51 @@ class Server {
35883637 let listening = false ;
35893638 let stopped = false ;
35903639
3640+ const childCompilers = /** @type {MultiCompiler } */ ( compiler )
3641+ . compilers || [ compiler ] ;
3642+ const seenFirstDone = new WeakSet ( ) ;
3643+ let firstDoneCount = 0 ;
3644+
3645+ // Returns true when a standalone `Server` is already attached to our
3646+ // compiler (or any of its children). This matters for `webpack serve`:
3647+ // it creates its own standalone server even if the user added a `Server`
3648+ // instance to `plugins[]`. In that case the plugin must stay passive —
3649+ // otherwise we'd try to bind the same port twice. Independent
3650+ // server/compiler pairs in the same process are unaffected because they
3651+ // don't share any compiler instance.
3652+ const isStandaloneRunning = ( ) => {
3653+ if ( activeStandaloneCompilers . has ( compiler ) ) return true ;
3654+ for ( const child of childCompilers ) {
3655+ if ( activeStandaloneCompilers . has ( child ) ) return true ;
3656+ }
3657+ return false ;
3658+ } ;
3659+
3660+ // A one-shot `compiler.run()` (plain `webpack` build) is detected when no
3661+ // child compiler is in watch mode. In that case we skip both `setup()` and
3662+ // `listen()` so the build can finish and the process can exit normally —
3663+ // the user is not in control of the plugin lifecycle here, so we stay
3664+ // silent rather than logging a warning.
3665+ const isBuildMode = ( ) =>
3666+ childCompilers . every ( ( child ) => ! child . watching && ! child . options . watch ) ;
3667+
35913668 /**
35923669 * @returns {Promise<void> } promise
35933670 */
35943671 const ensureSetup = ( ) => {
3672+ if ( isStandaloneRunning ( ) || isBuildMode ( ) ) return Promise . resolve ( ) ;
35953673 if ( ! setupPromise ) {
35963674 setupPromise = this . setup ( ) ;
35973675 }
35983676 return setupPromise ;
35993677 } ;
36003678
3601- const childCompilers = /** @type {MultiCompiler } */ ( compiler )
3602- . compilers || [ compiler ] ;
3603- const seenFirstDone = new WeakSet ( ) ;
3604- let firstDoneCount = 0 ;
3605-
36063679 /**
36073680 * @param {Compiler } childCompiler child compiler
36083681 * @returns {Promise<void> } promise
36093682 */
36103683 const onChildDone = async ( childCompiler ) => {
3611- if ( listening ) return ;
3684+ if ( listening || isStandaloneRunning ( ) || isBuildMode ( ) ) return ;
36123685 if ( seenFirstDone . has ( childCompiler ) ) return ;
36133686 seenFirstDone . add ( childCompiler ) ;
36143687 firstDoneCount ++ ;
0 commit comments