Skip to content

feat(cli): add run-multi command for parallel instances#7

Closed
armamini wants to merge 2 commits intossmirr:masterfrom
armamini:feature/multi-instance-runner
Closed

feat(cli): add run-multi command for parallel instances#7
armamini wants to merge 2 commits intossmirr:masterfrom
armamini:feature/multi-instance-runner

Conversation

@armamini
Copy link
Copy Markdown

Hey @ssmirr
To support your move, I added a simple way to run multiple Conduit instances in parallel on a VPS. Each instance gets its own key and reputation, so you can help more users with the same server.

Why?

  • More instances = more reputation with Psiphon broker
  • If one crashes, others keep running
  • Better use of beefy servers

Usage

# Run 4 instances
conduit run-multi -n 4 -c ./config.json
# With custom limits
conduit run-multi -n 8 -c ./config.json -m 100 -b 20 -v
# With stats files
conduit run-multi -n 4 -c ./config.json --stats-file stats.json

Add new 'run-multi' command that runs multiple Conduit inproxy instances
in parallel for high-capacity VPS/server deployments.
Features:
- Run 1-32 parallel instances with --instances flag
- Each instance gets separate data directory and key
- Aggregated stats logging every 10 seconds
- Per-instance stats files with --stats-file pattern
- Graceful shutdown of all instances on Ctrl+C
@ssmirr
Copy link
Copy Markdown
Owner

ssmirr commented Jan 26, 2026

I like the idea @armamini ! I might suggest one change to this if you can work on it, but I have two questions first:

  • Have you tested how well multiple conduits from the same IP address works?
  • Have you confirmed all instances regularly receive connections from the broker?

@armamini
Copy link
Copy Markdown
Author

Thanks for the feedback @ssmirr ! 🙏

Honest answer: I haven't tested with a real Psiphon network config, so I can't confirm:

  1. Multiple conduits from same IP - No real-world testing yet
  2. Broker connection distribution - Haven't verified

I only tested the structural parts:

  • Separate directories created (instance-0/, instance-1/, etc.)
  • Separate keys generated for each instance
  • Graceful startup/shutdown of all instances

To properly test, I'd need a valid psiphon_config.json. If you can provide a test one (or point me to how to get one), I'd be happy to run real-world tests and report back!
Alternatively, I think we need to connect with Psiphon INC techniqual support team to test it in a dev environment.

@ssmirr
Copy link
Copy Markdown
Owner

ssmirr commented Jan 26, 2026

I am testing it now, thank you for the quick response!
If this idea works, I'm going to suggests a few changes, but this feature gets my highest priority for things to be merged as it's a performance related enhancement.

@armamini
Copy link
Copy Markdown
Author

@ssmirr
Sure, anytime! Thanks for prioritizing this. 🙌
I'm standing by for your feedback. Happy to make any changes or improvements you suggest once you've verified the core idea works.
Cheers!

@ssmirr
Copy link
Copy Markdown
Owner

ssmirr commented Jan 26, 2026

Thank you @armamini ! So far seeing great results. I will keep my test running for an hour or so and update here.

@ssmirr
Copy link
Copy Markdown
Owner

ssmirr commented Jan 26, 2026

@armamini I'm convinced the idea works. Below are my suggestions if you can make progress I will be able to review in a few hours later today!

Note: I know it is going to be probably lots of changes. I also know what I'm asking to change here is very opinionated. But trust me... it's based on actual data from testing (myself and many different people using the tool) as well as what I've heard and seen from the original team when they gave me feedback on the original PR.

Instead of making the separate multi-instance command, update the start command to:

  • check value of -b and make a separate instance internally per each 100 connections
  • make this ^ only happen if the user sets --multi-instance on the start command. Otherwise behave same as current code which makes one instance.
  • please have separate data dir for each instance, name them based on the key generation step information.
  • the logs that are printed as output should all be merged but you can prefix each line with a unique identifier to show which instance it is from

Please see how much progress you can make if you have time. I will try to get to this later today (hopefully 🤞🏼). Thanks again for your contribution!

Integrate multi-instance into start command instead of separate run-multi.
Auto-splits based on max-clients (1 instance per 100 clients).
Names directories by key short hash for easy identification.
Prefixes all logs with key hash for merged output.
@armamini
Copy link
Copy Markdown
Author

Hey @ssmirr

Thanks for your feedback! Based on your suggestion, I've refactored the multi-instance feature:

  • Before: Separate run-multi command
  • After: Integrated into start command with --multi-instance flag

So how does it work now?

# Single instance (default behavior - unchanged)
conduit start -c config.json -m 50
# Multi-instance mode (new)
conduit start -c config.json -m 300 --multi-instance

When --multi-instance is enabled:

  • Auto-splits instances based on --max-clients (1 instance per 100 clients)
  • Example: -m 300 → 3 instances with 100 clients each
  • Each instance gets a separate data directory named by key hash (e.g., a1b2c3d4/)
  • All logs are merged with key-hash prefix: [a1b2c3d4] Connected to broker...

Fancy test output

$ ./dist/conduit start -c config.json -m 350 --multi-instance -v
Starting 4 Psiphon Conduit instances (Max Clients/instance: 87, Bandwidth: 40 Mbps)
[acad1941] Starting with data dir: ./data/acad1941
[53d2cd8a] Starting with data dir: ./data/53d2cd8a
[0307eae3] Starting with data dir: ./data/0307eae3
[04004df3] Starting with data dir: ./data/04004df3

Ba Ehteram!
Regards!

@paradixe
Copy link
Copy Markdown

Here's my testing:

  • Tested this on 13 servers. Ran into an issue where only 1 of N instances showed as "Live" - the rest would connect but never report their status properly.

  • Root cause: psiphon.SetNoticeWriter() is global. Each instance calls it during startup, so the last one wins and only that instance receives notices.

  • Fixed it with a notice multiplexer - register all instance handlers first, then call SetNoticeWriter once with a dispatcher that fans out to all of them:

multiplexer

package conduit                                                                                                                                                                           
                                                                                                                                                                                          
import (                                                                                                                                                                                  
      "context"                                                                                                                                                                           
      "fmt"                                                                                                                                                                               
      "sync"                                                                                                                                                                              
      "time"                                                                                                                                                                              
                                                                                                                                                                                          
      "github.com/Psiphon-Inc/conduit/cli/internal/config"                                                                                                                                
      "github.com/Psiphon-Labs/psiphon-tunnel-core/psiphon"                                                                                                                               
)                                                                                                                                                                                         
                                                                                                                                                                                          
// NoticeMultiplexer dispatches psiphon notices to multiple handlers                                                                                                                      
type NoticeMultiplexer struct {                                                                                                                                                           
      handlers []func([]byte)                                                                                                                                                             
      mu       sync.RWMutex                                                                                                                                                               
}                                                                                                                                                                                         
                                                                                                                                                                                          
func (m *NoticeMultiplexer) AddHandler(h func([]byte)) {                                                                                                                                  
      m.mu.Lock()                                                                                                                                                                         
      m.handlers = append(m.handlers, h)                                                                                                                                                  
      m.mu.Unlock()                                                                                                                                                                       
}                                                                                                                                                                                         
                                                                                                                                                                                          
func (m *NoticeMultiplexer) Handle(notice []byte) {                                                                                                                                       
      m.mu.RLock()                                                                                                                                                                        
      defer m.mu.RUnlock()                                                                                                                                                                
      for _, h := range m.handlers {                                                                                                                                                      
              h(notice)                                                                                                                                                                   
      }                                                                                                                                                                                   
}                                                                                                                                                                                         
                                                                                                                                                                                          
func NewMultiService(configs []*config.Config) (*MultiService, error) {                                                                                                                   
      mux := &NoticeMultiplexer{}                                                                                                                                                         
                                                                                                                                                                                          
      instances := make([]*Instance, len(configs))                                                                                                                                        
      for i, cfg := range configs {                                                                                                                                                       
              service, _ := New(cfg)                                                                                                                                                      
              service.SetSharedMultiplexer()                                                                                                                                              
              mux.AddHandler(service.GetNoticeHandler())                                                                                                                                  
              instances[i] = &Instance{ID: i, Config: cfg, Service: service}                                                                                                              
      }                                                                                                                                                                                   
                                                                                                                                                                                          
      // Key fix: set global notice writer ONCE with multiplexer                                                                                                                          
      psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver(mux.Handle))                                                                                                                      
                                                                                                                                                                                          
      return &MultiService{instances: instances, multiplexer: mux}, nil                                                                                                                   
}                                                                                                                                                                                         

service.go

  // Service struct - add field:                                                                                                                                                            
  type Service struct {                                                                                                                                                                     
        config        *config.Config                                                                                                                                                        
        controller    *psiphon.Controller                                                                                                                                                   
        stats         *Stats                                                                                                                                                                
        mu            sync.RWMutex                                                                                                                                                          
        useSharedMux  bool // If true, don't set global notice writer (multi-instance mode)                                                                                                 
  }                                                                                                                                                                                         
                                                                                                                                                                                            
  // Add new type and methods:                                                                                                                                                              
  type NoticeHandler func([]byte)                                                                                                                                                           
                                                                                                                                                                                            
  func (s *Service) SetSharedMultiplexer() {                                                                                                                                                
        s.useSharedMux = true                                                                                                                                                               
  }                                                                                                                                                                                         
                                                                                                                                                                                            
  func (s *Service) GetNoticeHandler() NoticeHandler {                                                                                                                                      
        return s.handleNotice                                                                                                                                                               
  }                                                                                                                                                                                         
                                                                                                                                                                                            
  // Modify Run() - wrap SetNoticeWriter in conditional:                                                                                                                                    
  func (s *Service) Run(ctx context.Context) error {                                                                                                                                        
        // Skip if using shared multiplexer (multi-instance mode)                                                                                                                           
        if !s.useSharedMux {                                                                                                                                                                
                psiphon.SetNoticeWriter(psiphon.NewNoticeReceiver(                                                                                                                          
                        func(notice []byte) {                                                                                                                                               
                                s.handleNotice(notice)                                                                                                                                      
                        },                                                                                                                                                                  
                ))                                                                                                                                                                          
        }                                                                                                                                                                                   
        // ... rest of Run() unchanged                                                                                                                                                      
  }                      

This is hacky and I'm not big on Go so feel free to ignore.

@ssmirr
Copy link
Copy Markdown
Owner

ssmirr commented Jan 27, 2026

Awesome work getting this @armamini and thank you very much @paradixe for the initial review and testing!

@ssmirr
Copy link
Copy Markdown
Owner

ssmirr commented Jan 27, 2026

The NoticeMultiplexer actually wouldn't work unfortunately (as far as I can figure out) . The ideal way would have been if we could:

  • call SetNoticeWriter() once with multiplexer handler
  • broadcast all notices to all instance handlers
  • each instance would be able to filter and figure out which is meant for that instance

The fundamental flaw is Psiphon notices don't include instance identification.

When a notice like {"noticeType":"InproxyProxyActivity","data":{"elapsedTime":60}} arrives at the multiplexer, there's no way to know which of the 3 running instances generated it. The multiplexer broadcasts it to all handlers, so:

  • Instance 1 thinks it handled those bytes
  • Instance 2 thinks it handled those bytes
  • Instance 3 thinks it handled those bytes

So... stats are triple-counted (or N-counted for N instances).

@ssmirr
Copy link
Copy Markdown
Owner

ssmirr commented Jan 27, 2026

BUT Psiphon also has a UseNoticeFiles config option solves this by having each instance write notices to its own file:

./data/1/ca.psiphon.PsiphonTunnel.tunnel-core/notices
./data/2/ca.psiphon.PsiphonTunnel.tunnel-core/notices
./data/3/ca.psiphon.PsiphonTunnel.tunnel-core/notices

Each instance:

  • Configures UseNoticeFiles in its Psiphon config
  • Tails its own notice file via watchNoticeFile()
  • Processes only its own notices
    No SetNoticeWriter() is called at all in multi-instance mode, completely avoiding the global singleton issue.

Lets see if this works 👀

@ssmirr
Copy link
Copy Markdown
Owner

ssmirr commented Jan 27, 2026

setNoticeFiles is also a GLOBAL SINGLETON 🙃

Sorry I don't think this approach would work at all without further changes to the upstream psiphone proxy code. I have an idea with a different approach to support multi instance and will add that separately outside of this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants