diff --git a/CHANGELOG.md b/CHANGELOG.md index ba1bd35..c6736c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `--lock PORT` now shows directory in success message (#77) - New format: `Locked port 3001 for 'main' in ~/project` +- SOURCE column in `--list` output showing allocation source (free/lock/external) (#73) +- `--refresh` command to clean up stale external port allocations (#73) +- Automatic registration of busy ports as external when using `--lock ` (#73) + - When a port is already in use by another directory, it's registered as "external" + - Prevents allocation conflicts while keeping track of all busy ports + - Stores process information (PID, user, process name) for external ports +- New logging events: `ALLOC_EXTERNAL`, `ALLOC_REFRESH` (#73) ### Changed - Smart `--force` logic for `--lock PORT` (#77) @@ -17,6 +24,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Free + locked port from another directory: requires `--force` - Busy port from another directory: blocked completely (stop the service first) - Busy unallocated port: requires `--force` (user takes responsibility) +- `--list` output now includes SOURCE column after NAME (#73) +- `--lock ` behavior when port is in use (#73) + - Same directory: port is locked for that directory + - Different directory: port is registered as external + - No longer fails when port is in use by another process ### Fixed - Locked+busy port now correctly returned by `port-selector` (#77) diff --git a/README.md b/README.md index ce73216..ca9cd4d 100644 --- a/README.md +++ b/README.md @@ -237,10 +237,10 @@ $ port-selector --name db # List shows NAME column $ port-selector --list -PORT DIRECTORY NAME STATUS LOCKED USER PID PROCESS ASSIGNED -3010 ~/myproject web free - - - - 2026-01-06 20:00 -3011 ~/myproject api free - - - - 2026-01-06 20:01 -3012 ~/myproject db free - - - - 2026-01-06 20:02 +PORT DIRECTORY NAME SOURCE STATUS LOCKED USER PID PROCESS ASSIGNED +3010 ~/myproject web free free - - - - 2026-01-06 20:00 +3011 ~/myproject api free free - - - - 2026-01-06 20:01 +3012 ~/myproject db free free - - - - 2026-01-06 20:02 ``` The default name is `main`, which is used when `--name` is not specified: @@ -262,11 +262,12 @@ Named allocations are useful for: port-selector --list # Output: -PORT DIRECTORY NAME STATUS LOCKED USER PID PROCESS ASSIGNED -3000 ~/code/merchantly/main main free yes - - - 2026-01-03 20:53 -3001 ~/code/valera main free yes - - - 2026-01-03 21:08 -3010 ~/myproject web free - - - - 2026-01-06 20:00 -3011 ~/myproject api free - - - - 2026-01-06 20:01 +PORT DIRECTORY NAME SOURCE STATUS LOCKED USER PID PROCESS ASSIGNED +3000 ~/code/merchantly/main main lock free yes - - - 2026-01-03 20:53 +3001 ~/code/valera main free free yes - - - 2026-01-03 21:08 +3010 ~/myproject web free free - - - - 2026-01-06 20:00 +3011 ~/myproject api free free - - - - 2026-01-06 20:01 +3500 ~/other-project main external busy - user 1234 python 2026-01-10 15:30 # # Tip: Run with sudo for full process info: sudo port-selector --list @@ -282,8 +283,20 @@ port-selector --forget --name web # Clear all allocations port-selector --forget-all # Cleared 5 allocation(s) + +# Refresh external port allocations (remove stale entries) +port-selector --refresh +# Refreshing 3 external allocation(s)... +# Removed 2 stale external allocation(s). ``` +The **SOURCE** column indicates where the port allocation came from: +- `free` — normal allocation, currently free to use +- `lock` — port is locked for this directory +- `external` — port is used by another directory/process + +External allocations are created automatically when you try to lock a port that's already in use by another directory/process. This prevents allocation conflicts while keeping track of busy ports. + ### Port Locking Lock a port to prevent it from being allocated to other directories. Useful for long-running services that should keep their port even when restarted: @@ -303,6 +316,11 @@ cd ~/projects/new-service port-selector --lock 3005 # Locked port 3005 for 'main' +# If port is already in use by another directory, it's registered as external +cd ~/projects/another-project +port-selector --lock 3005 +# Port 3005 is externally used by python, registered as external + # Unlock port for current directory port-selector --unlock # Unlocked port 3000 for 'main' @@ -318,6 +336,9 @@ port-selector --unlock 3005 When using `--lock ` with a specific port number: - If the port is not allocated, it will be allocated to the current directory AND locked +- If the port is in use by the same directory, it will be marked as locked +- If the port is in use by another directory, it will be registered as an **external** allocation +- This prevents conflicts while keeping track of all busy ports - This is useful when you want a specific port for a new project - The port must be within the configured range @@ -423,6 +444,7 @@ Options: --forget --name NAME Clear port allocation for current directory with specific name --forget-all Clear all port allocations --scan Scan port range and record busy ports with their directories + --refresh Refresh external port allocations (remove stale entries) --name NAME Use named allocation (default: "main") --verbose Enable debug output (can be combined with other flags) ``` @@ -503,6 +525,8 @@ Logged events: - `ALLOC_DELETE` — allocation removed (--forget) - `ALLOC_DELETE_ALL` — all allocations removed (--forget-all) - `ALLOC_EXPIRE` — allocation expired by TTL +- `ALLOC_EXTERNAL` — external port allocation registered +- `ALLOC_REFRESH` — external allocations refreshed ### Allocation TTL diff --git a/README.ru.md b/README.ru.md index 53983b1..5720fcd 100644 --- a/README.ru.md +++ b/README.ru.md @@ -237,10 +237,10 @@ $ port-selector --name db # Список показывает колонку NAME $ port-selector --list -PORT DIRECTORY NAME STATUS LOCKED USER PID PROCESS ASSIGNED -3010 ~/myproject web free - - - - 2026-01-06 20:00 -3011 ~/myproject api free - - - - 2026-01-06 20:01 -3012 ~/myproject db free - - - - 2026-01-06 20:02 +PORT DIRECTORY NAME SOURCE STATUS LOCKED USER PID PROCESS ASSIGNED +3010 ~/myproject web free free - - - - 2026-01-06 20:00 +3011 ~/myproject api free free - - - - 2026-01-06 20:01 +3012 ~/myproject db free free - - - - 2026-01-06 20:02 ``` Имя по умолчанию — `main`, используется когда `--name` не указан: @@ -262,11 +262,12 @@ $ port-selector --name main # То же самое port-selector --list # Вывод: -PORT DIRECTORY NAME STATUS LOCKED USER PID PROCESS ASSIGNED -3000 ~/code/merchantly/main main free yes - - - 2026-01-03 20:53 -3001 ~/code/valera main free yes - - - 2026-01-03 21:08 -3010 ~/myproject web free - - - - 2026-01-06 20:00 -3011 ~/myproject api free - - - - 2026-01-06 20:01 +PORT DIRECTORY NAME SOURCE STATUS LOCKED USER PID PROCESS ASSIGNED +3000 ~/code/merchantly/main main lock free yes - - - 2026-01-03 20:53 +3001 ~/code/valera main free free yes - - - 2026-01-03 21:08 +3010 ~/myproject web free free - - - - 2026-01-06 20:00 +3011 ~/myproject api free free - - - - 2026-01-06 20:01 +3500 ~/other-project main external busy - user 1234 python 2026-01-10 15:30 # # Совет: Запустите с sudo для полной информации о процессах: sudo port-selector --list @@ -282,8 +283,20 @@ port-selector --forget --name web # Удалить все аллокации port-selector --forget-all # Cleared 5 allocation(s) + +# Обновить внешние аллокации (удалить устаревшие) +port-selector --refresh +# Refreshing 3 external allocation(s)... +# Removed 2 stale external allocation(s). ``` +Колонка **SOURCE** показывает источник аллокации: +- `free` — обычная аллокация, порт сейчас свободен +- `lock` — порт заблокирован за этой директорией +- `external` — порт используется другой директорией/процессом + +Внешние аллокации создаются автоматически, когда вы пытаетесь заблокировать порт, который уже занят другой директорией/процессом. Это предотвращает конфликты при выделении портов, отслеживая занятые порты. + ### Блокировка портов Заблокируйте порт, чтобы он не мог быть выделен другим директориям. Полезно для долгоживущих сервисов, которым нужно сохранять свой порт даже при перезапуске: @@ -303,6 +316,11 @@ cd ~/projects/new-service port-selector --lock 3005 # Locked port 3005 for 'main' +# Если порт занят другой директорией, он регистрируется как external +cd ~/projects/another-project +port-selector --lock 3005 +# Port 3005 is externally used by python, registered as external + # Разблокировать порт для текущей директории port-selector --unlock # Unlocked port 3000 for 'main' @@ -318,6 +336,9 @@ port-selector --unlock 3005 При использовании `--lock ` с конкретным номером порта: - Если порт не выделен, он будет выделен текущей директории И заблокирован +- Если порт занят той же директорией, он будет помечен как заблокированный +- Если порт занят другой директорией, он будет зарегистрирован как **external** аллокация +- Это предотвращает конфликты, отслеживая все занятые порты - Это удобно, когда вы хотите конкретный порт для нового проекта - Порт должен находиться в настроенном диапазоне @@ -423,6 +444,7 @@ Options: --forget --name NAME Удалить аллокацию с указанным именем для текущей директории --forget-all Удалить все аллокации --scan Просканировать порты и записать занятые с их директориями + --refresh Обновить внешние аллокации (удалить устаревшие) --name NAME Использовать именованную аллокацию (по умолчанию: "main") --verbose Включить debug-вывод (можно комбинировать с другими флагами) ``` @@ -503,6 +525,8 @@ log: ~/.config/port-selector/port-selector.log - `ALLOC_DELETE` — аллокация удалена (--forget) - `ALLOC_DELETE_ALL` — все аллокации удалены (--forget-all) - `ALLOC_EXPIRE` — аллокация истекла по TTL +- `ALLOC_EXTERNAL` — зарегистрирована внешняя аллокация порта +- `ALLOC_REFRESH` — обновлены внешние аллокации ### TTL аллокаций diff --git a/cmd/port-selector/main.go b/cmd/port-selector/main.go index 04030f8..26df6f6 100644 --- a/cmd/port-selector/main.go +++ b/cmd/port-selector/main.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "strconv" "strings" "text/tabwriter" @@ -265,6 +266,12 @@ func main() { os.Exit(1) } return + case "--refresh": + if err := runRefresh(); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } + return case "-c", "--lock": name, remainingArgs, err := parseNameFromArgs(args[1:]) if err != nil { @@ -586,13 +593,22 @@ func runSetLocked(name string, portArg int, locked bool, force bool) error { var targetPort int var reassignedFrom string + var isExternal bool + var externalProcessName string err = allocations.WithStore(configDir, func(store *allocations.Store) error { var lockErr error if portArg > 0 { - targetPort, reassignedFrom, lockErr = lockSpecificPort(store, name, portArg, cwd, locked, force) + targetPort, reassignedFrom, isExternal, lockErr = lockSpecificPort(store, name, portArg, cwd, locked, force) } else { targetPort, lockErr = lockCurrentDirectory(store, name, cwd, locked) } + // Check if this is an external allocation and save process name + if alloc := store.FindByPort(targetPort); alloc != nil { + if alloc.Status == allocations.StatusExternal { + isExternal = true + externalProcessName = alloc.ExternalProcessName + } + } return lockErr }) @@ -600,6 +616,15 @@ func runSetLocked(name string, portArg int, locked bool, force bool) error { return err } + // Handle external allocation message + if isExternal && locked { + if externalProcessName == "" { + externalProcessName = "unknown process" + } + fmt.Printf("Port %d is externally used by %s, registered as external\n", targetPort, externalProcessName) + return nil + } + // Print warning if port was reassigned from another directory if reassignedFrom != "" { fmt.Fprintf(os.Stderr, "warning: port %d was allocated to %s\n", targetPort, pathutil.ShortenHomePath(reassignedFrom)) @@ -615,14 +640,14 @@ func runSetLocked(name string, portArg int, locked bool, force bool) error { } // lockSpecificPort handles locking/unlocking a specific port number. -// Returns the port, the old directory (if reassigned), and any error. +// Returns the port, the old directory (if reassigned), isExternal flag, and any error. // // Decision Matrix for --lock PORT: // - Require --force if: port is locked for another directory // - Block completely (even with --force) if: port is busy on another directory // - Allow without --force if: port not allocated, or allocated but free and unlocked -// - Special case: port busy but not in allocations — without --force error, with --force create allocation -func lockSpecificPort(store *allocations.Store, name string, portArg int, cwd string, locked bool, force bool) (int, string, error) { +// - Special case: port busy but not in allocations — register as external allocation +func lockSpecificPort(store *allocations.Store, name string, portArg int, cwd string, locked bool, force bool) (int, string, bool, error) { isBusy := !port.IsPortFree(portArg) alloc := store.FindByPort(portArg) @@ -630,16 +655,17 @@ func lockSpecificPort(store *allocations.Store, name string, portArg int, cwd st // Port already allocated if alloc.Directory == cwd { // Port belongs to current directory - just update lock status + // Note: SetLockedByPort already updates LockedAt timestamp when locking if !store.SetLockedByPort(portArg, locked) { - return 0, "", fmt.Errorf("internal error: allocation for port %d disappeared unexpectedly", portArg) + return 0, "", false, fmt.Errorf("internal error: allocation for port %d disappeared unexpectedly", portArg) } - return portArg, "", nil + return portArg, "", false, nil } // Port belongs to another directory if isBusy { // Port is busy on another directory — block completely (even with --force) - return 0, "", fmt.Errorf("port %d is in use by %s; stop the service first", + return 0, "", false, fmt.Errorf("port %d is in use by %s; stop the service first", portArg, pathutil.ShortenHomePath(alloc.Directory)) } @@ -647,7 +673,7 @@ func lockSpecificPort(store *allocations.Store, name string, portArg int, cwd st if alloc.Locked { // Require --force to reassign locked port if !force { - return 0, "", fmt.Errorf("port %d is locked by %s\n use --lock %d --force to reassign it to current directory", + return 0, "", false, fmt.Errorf("port %d is locked by %s\n use --lock %d --force to reassign it to current directory", portArg, pathutil.ShortenHomePath(alloc.Directory), portArg) } } @@ -655,37 +681,60 @@ func lockSpecificPort(store *allocations.Store, name string, portArg int, cwd st oldDir := alloc.Directory store.RemoveByPort(portArg) store.SetAllocationWithName(cwd, portArg, name) + // Note: SetLockedByPort already updates LockedAt timestamp when locking if !store.SetLockedByPort(portArg, true) { - return 0, "", fmt.Errorf("internal error: failed to lock port %d after reassignment", portArg) + return 0, "", false, fmt.Errorf("internal error: failed to lock port %d after reassignment", portArg) } // Unlock any previously locked ports for this directory+name (invariant: at most one locked) // This is done AFTER locking the new port so old locked ports are preserved during SetAllocation store.UnlockOtherLockedPorts(cwd, name, portArg) - return portArg, oldDir, nil + return portArg, oldDir, false, nil } // Port not allocated yet if !locked { - return 0, "", fmt.Errorf("no allocation found for port %d", portArg) + return 0, "", false, fmt.Errorf("no allocation found for port %d", portArg) } // Try to allocate and lock the port cfg, err := config.Load() if err != nil { - return 0, "", fmt.Errorf("failed to load config: %w", err) + return 0, "", false, fmt.Errorf("failed to load config: %w", err) } if portArg < cfg.PortStart || portArg > cfg.PortEnd { - return 0, "", fmt.Errorf("port %d is outside configured range %d-%d", portArg, cfg.PortStart, cfg.PortEnd) + return 0, "", false, fmt.Errorf("port %d is outside configured range %d-%d", portArg, cfg.PortStart, cfg.PortEnd) } if isBusy { - // Port busy but NOT in allocations - if !force { - if procInfo := port.GetPortProcess(portArg); procInfo != nil { - return 0, "", fmt.Errorf("port %d is in use by another process (%s)", portArg, procInfo) + // Port is busy - get process info and decide what to do + procInfo := port.GetPortProcess(portArg) + + // Normalize paths for comparison + cwdNormalized := filepath.Clean(cwd) + var procCwdNormalized string + if procInfo != nil && procInfo.Cwd != "" { + procCwdNormalized = filepath.Clean(procInfo.Cwd) + } + + // Case 1: Same directory - register as locked + if procInfo != nil && procCwdNormalized == cwdNormalized { + store.SetAllocationWithName(cwd, portArg, name) + if !store.SetLockedByPort(portArg, true) { + return 0, "", false, fmt.Errorf("internal error: failed to lock port %d", portArg) } - return 0, "", fmt.Errorf("port %d is in use", portArg) + return portArg, "", false, nil + } + + // Case 2: Different directory - register as external + if procInfo != nil { + store.SetExternalAllocation(portArg, procInfo.PID, procInfo.User, procInfo.Name, procInfo.Cwd) + return portArg, "", true, nil + } + + // Case 3: No process info available - require --force + if !force { + return 0, "", false, fmt.Errorf("port %d is in use by unknown process", portArg) } // With --force: create allocation even though port is busy (user takes responsibility) } @@ -693,15 +742,16 @@ func lockSpecificPort(store *allocations.Store, name string, portArg int, cwd st // Allocate and lock the port for this directory and name // SetAllocationWithName preserves locked ports (they won't be deleted) store.SetAllocationWithName(cwd, portArg, name) + // Note: SetLockedByPort already updates LockedAt timestamp when locking if !store.SetLockedByPort(portArg, true) { - return 0, "", fmt.Errorf("internal error: failed to lock port %d after allocation", portArg) + return 0, "", false, fmt.Errorf("internal error: failed to lock port %d after allocation", portArg) } // Unlock any previously locked ports for this directory+name (invariant: at most one locked) // This is done AFTER locking the new port so old locked ports are preserved during SetAllocation store.UnlockOtherLockedPorts(cwd, name, portArg) - return portArg, "", nil + return portArg, "", false, nil } // lockCurrentDirectory handles locking/unlocking the port for the current directory and name. @@ -780,7 +830,7 @@ func runList() error { // Second pass: format and print output w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "PORT\tDIRECTORY\tNAME\tSTATUS\tLOCKED\tUSER\tPID\tPROCESS\tASSIGNED") + fmt.Fprintln(w, "PORT\tDIRECTORY\tNAME\tSOURCE\tSTATUS\tLOCKED\tUSER\tPID\tPROCESS\tASSIGNED") hasIncompleteInfo := false @@ -791,12 +841,36 @@ func runList() error { process := "-" directory := alloc.Directory - // Use saved process name from allocation if available - if alloc.ProcessName != "" { - process = truncateProcessName(alloc.ProcessName) + // Determine SOURCE and use saved external info for external allocations + source := "free" + if alloc.Status == allocations.StatusExternal { + source = "external" + // For external allocations, use saved process info + if alloc.ExternalUser != "" { + username = alloc.ExternalUser + } + if alloc.ExternalPID > 0 { + pid = strconv.Itoa(alloc.ExternalPID) + } + if alloc.ExternalProcessName != "" { + process = truncateProcessName(alloc.ExternalProcessName) + } + status = "busy" // External ports are always busy + } else if alloc.Locked { + source = "lock" + // Use saved process name from allocation if available + if alloc.ProcessName != "" { + process = truncateProcessName(alloc.ProcessName) + } + } else { + // Normal allocation - use saved process name if available + if alloc.ProcessName != "" { + process = truncateProcessName(alloc.ProcessName) + } } - if !port.IsPortFree(alloc.Port) { + // For non-external allocations, check live port status + if alloc.Status != allocations.StatusExternal && !port.IsPortFree(alloc.Port) { status = "busy" if procInfo := port.GetPortProcess(alloc.Port); procInfo != nil { if procInfo.User != "" { @@ -849,7 +923,7 @@ func runList() error { shortDir = truncateDirectoryPath(shortDir, maxDirWidth) } - fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", alloc.Port, shortDir, nameStr, status, locked, username, pid, process, timestamp) + fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", alloc.Port, shortDir, nameStr, source, status, locked, username, pid, process, timestamp) } w.Flush() @@ -878,6 +952,7 @@ Options: --forget --name NAME Clear port allocation for current directory with specific name --forget-all Clear all port allocations --scan Scan port range and record busy ports with their directories + --refresh Refresh external port allocations (remove stale entries) --name NAME Use named allocation (default: "main") --verbose Enable debug output (can be combined with other flags) @@ -896,6 +971,7 @@ Examples: port-selector --unlock --name db # Unlock "db" allocation port-selector --forget # Forget all allocations for directory port-selector --forget --name api # Forget only "api" allocation + port-selector --refresh # Remove stale external port allocations Port Locking: Locked ports are reserved and won't be allocated to other directories. @@ -912,6 +988,9 @@ Port Locking: When --lock PORT targets a busy unallocated port: - Requires --force (you take responsibility for the conflict) + If the port is already in use by another directory, it will be + registered as an external allocation instead of failing. + Configuration: ~/.config/port-selector/default.yaml @@ -1026,3 +1105,46 @@ func runScan() error { return nil } + +func runRefresh() error { + if _, err := loadConfigAndInitLogger(); err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + configDir, err := config.ConfigDir() + if err != nil { + return fmt.Errorf("failed to get config dir: %w", err) + } + + var removedCount int + var totalCount int + + err = allocations.WithStore(configDir, func(store *allocations.Store) error { + for _, info := range store.Allocations { + if info != nil && info.Status == allocations.StatusExternal { + totalCount++ + } + } + + if totalCount == 0 { + fmt.Println("No external port allocations found.") + return nil + } + + fmt.Printf("Refreshing %d external allocation(s)...\n", totalCount) + removedCount = store.RefreshExternalAllocations(port.IsPortFree) + return nil + }) + + if err != nil { + return err + } + + if removedCount > 0 { + fmt.Printf("Removed %d stale external allocation(s).\n", removedCount) + } else if totalCount > 0 { + fmt.Println("All external allocations are still active.") + } + + return nil +} diff --git a/cmd/port-selector/main_test.go b/cmd/port-selector/main_test.go index ae036fb..798b833 100644 --- a/cmd/port-selector/main_test.go +++ b/cmd/port-selector/main_test.go @@ -291,16 +291,33 @@ func TestLockPortInUseByAnotherProcess(t *testing.T) { } defer ln.Close() - // Test: --lock 3500 should fail (port in use) + // Test: --lock 3500 should now succeed (registers as external) + // The port is in use by a process (the listener), but it's in a different + // directory, so it should be registered as external cmd := exec.Command(binary, "--lock", "3500") cmd.Dir = workDir cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+filepath.Join(tmpDir, ".config")) output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected error, got success with output: %s", output) + if err != nil { + t.Fatalf("expected success, got error: %v, output: %s", err, output) + } + + // Should succeed - either as external (different process) or registered + // The exact output depends on whether port.GetPortProcess can identify our listener + // For this test, we just verify it doesn't fail with "in use" error + if strings.Contains(string(output), "in use by unknown process") { + // This is acceptable - means we couldn't get process info but still handled it + return } - if !strings.Contains(string(output), "in use") { - t.Errorf("expected 'in use' error, got: %s", output) + + // Verify an allocation was created (either external or normal) + allocs, loadErr := allocations.Load(configDir) + if loadErr != nil { + t.Fatalf("failed to load allocations: %v", loadErr) + } + alloc := allocs.FindByPort(3500) + if alloc == nil { + t.Error("allocation for port 3500 should have been created") } } @@ -753,7 +770,7 @@ func TestLockPort_BusyFromOtherDir_BlocksEvenWithForce(t *testing.T) { } } -func TestLockPort_BusyNotAllocated_RequiresForce(t *testing.T) { +func TestLockPort_BusyNotAllocated_RegistersAsExternal(t *testing.T) { binary := buildBinary(t) tmpDir := t.TempDir() @@ -767,7 +784,7 @@ func TestLockPort_BusyNotAllocated_RequiresForce(t *testing.T) { t.Fatal(err) } - // Occupy port to simulate busy port + // Occupy port to simulate busy port from another directory ln, err := net.Listen("tcp", ":3012") if err != nil { t.Skipf("could not occupy port 3012 for test: %v", err) @@ -776,35 +793,33 @@ func TestLockPort_BusyNotAllocated_RequiresForce(t *testing.T) { env := append(os.Environ(), "XDG_CONFIG_HOME="+filepath.Join(tmpDir, ".config")) - // Try to lock without --force (should fail) + // Try to lock port that's in use - should register as external (not fail) cmd := exec.Command(binary, "--lock", "3012") cmd.Dir = workDir cmd.Env = env output, err := cmd.CombinedOutput() - if err == nil { - t.Fatalf("expected error (busy port not allocated), got success: %s", output) - } - if !strings.Contains(string(output), "in use") { - t.Errorf("expected 'in use' error, got: %s", output) + // With new behavior, busy port with process info is registered as external + if err != nil { + // If it fails, it should be because no process info is available + if !strings.Contains(string(output), "unknown process") { + t.Fatalf("expected external registration or unknown process error, got: %s", output) + } + return // Test passes - no process info available } - // Try with --force (should succeed - user takes responsibility) - cmd = exec.Command(binary, "--lock", "--force", "3012") - cmd.Dir = workDir - cmd.Env = env - output, err = cmd.CombinedOutput() - if err != nil { - t.Fatalf("expected success with --force, got error: %v, output: %s", err, output) + // Check output indicates external registration + if !strings.Contains(string(output), "external") { + t.Errorf("expected 'external' in output, got: %s", output) } - // Verify allocation was created + // Verify allocation was created as external loaded, _ := allocations.Load(configDir) alloc := loaded.FindByPort(3012) if alloc == nil { t.Fatal("expected allocation for port 3012") } - if alloc.Directory != workDir { - t.Errorf("expected port to belong to %s, got %s", workDir, alloc.Directory) + if alloc.Status != "external" { + t.Errorf("expected status 'external', got %q", alloc.Status) } } @@ -1049,3 +1064,181 @@ func TestLockPort_SameDirectorySamePortIdempotent(t *testing.T) { t.Error("expected port to remain locked") } } + +// Tests for --refresh command (issue #73) + +func TestRefresh_NoExternalAllocations(t *testing.T) { + binary := buildBinary(t) + + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, ".config", "port-selector") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + workDir := filepath.Join(tmpDir, "project") + if err := os.MkdirAll(workDir, 0755); err != nil { + t.Fatal(err) + } + + // Run --refresh with no allocations + cmd := exec.Command(binary, "--refresh") + cmd.Dir = workDir + cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+filepath.Join(tmpDir, ".config")) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected success, got error: %v, output: %s", err, output) + } + + if !strings.Contains(string(output), "No external port allocations found") { + t.Errorf("expected 'No external port allocations found', got: %s", output) + } +} + +func TestRefresh_RemovesStaleExternalAllocations(t *testing.T) { + binary := buildBinary(t) + + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, ".config", "port-selector") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + workDir := filepath.Join(tmpDir, "project") + if err := os.MkdirAll(workDir, 0755); err != nil { + t.Fatal(err) + } + + // Create external allocation for a free port + store := allocations.NewStore() + store.SetExternalAllocation(3600, 99999, "testuser", "defunct", "/tmp/defunct") + if err := allocations.Save(configDir, store); err != nil { + t.Fatal(err) + } + + // Run --refresh - should remove the stale allocation (port is free) + cmd := exec.Command(binary, "--refresh") + cmd.Dir = workDir + cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+filepath.Join(tmpDir, ".config")) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected success, got error: %v, output: %s", err, output) + } + + if !strings.Contains(string(output), "Removed 1 stale") { + t.Errorf("expected 'Removed 1 stale', got: %s", output) + } + + // Verify allocation was removed + loaded, loadErr := allocations.Load(configDir) + if loadErr != nil { + t.Fatalf("failed to load allocations: %v", loadErr) + } + if loaded.FindByPort(3600) != nil { + t.Error("stale external allocation should have been removed") + } +} + +func TestRefresh_KeepsActiveExternalAllocations(t *testing.T) { + binary := buildBinary(t) + + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, ".config", "port-selector") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + workDir := filepath.Join(tmpDir, "project") + if err := os.MkdirAll(workDir, 0755); err != nil { + t.Fatal(err) + } + + // Occupy a port + ln, err := net.Listen("tcp", ":3601") + if err != nil { + t.Skipf("could not occupy port 3601 for test: %v", err) + } + defer ln.Close() + + // Create external allocation for the busy port + store := allocations.NewStore() + store.SetExternalAllocation(3601, 12345, "testuser", "testprocess", "/tmp/test") + if err := allocations.Save(configDir, store); err != nil { + t.Fatal(err) + } + + // Run --refresh - should keep the allocation (port is busy) + cmd := exec.Command(binary, "--refresh") + cmd.Dir = workDir + cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+filepath.Join(tmpDir, ".config")) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected success, got error: %v, output: %s", err, output) + } + + if !strings.Contains(string(output), "All external allocations are still active") { + t.Errorf("expected 'All external allocations are still active', got: %s", output) + } + + // Verify allocation still exists + loaded, loadErr := allocations.Load(configDir) + if loadErr != nil { + t.Fatalf("failed to load allocations: %v", loadErr) + } + if loaded.FindByPort(3601) == nil { + t.Error("active external allocation should have been kept") + } +} + +func TestList_ShowsSourceColumn(t *testing.T) { + binary := buildBinary(t) + + tmpDir := t.TempDir() + configDir := filepath.Join(tmpDir, ".config", "port-selector") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + + workDir := filepath.Join(tmpDir, "project") + if err := os.MkdirAll(workDir, 0755); err != nil { + t.Fatal(err) + } + + // Create allocations with different sources + store := allocations.NewStore() + // Normal (free) allocation + store.SetAllocation("/tmp/project1", 3700) + // Locked allocation + store.SetAllocation("/tmp/project2", 3701) + store.SetLockedByPort(3701, true) + // External allocation + store.SetExternalAllocation(3702, 12345, "user", "process", "/tmp/external") + if err := allocations.Save(configDir, store); err != nil { + t.Fatal(err) + } + + // Run --list + cmd := exec.Command(binary, "--list") + cmd.Dir = workDir + cmd.Env = append(os.Environ(), "XDG_CONFIG_HOME="+filepath.Join(tmpDir, ".config")) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("expected success, got error: %v, output: %s", err, output) + } + + // Verify SOURCE column header exists + if !strings.Contains(string(output), "SOURCE") { + t.Errorf("expected SOURCE column header, got: %s", output) + } + + // Verify different source values + if !strings.Contains(string(output), "free") { + t.Errorf("expected 'free' source for normal allocation, got: %s", output) + } + if !strings.Contains(string(output), "lock") { + t.Errorf("expected 'lock' source for locked allocation, got: %s", output) + } + if !strings.Contains(string(output), "external") { + t.Errorf("expected 'external' source for external allocation, got: %s", output) + } +} diff --git a/internal/allocations/allocations.go b/internal/allocations/allocations.go index 25a27ce..0809859 100644 --- a/internal/allocations/allocations.go +++ b/internal/allocations/allocations.go @@ -17,15 +17,32 @@ import ( const allocationsFileName = "allocations.yaml" +// UnknownDirectoryFormat is the format string for unknown directory placeholders. +const UnknownDirectoryFormat = "(unknown:%d)" + +// AllocationStatus represents the type of allocation. +type AllocationStatus string + +// Status constants for allocations. +const ( + StatusNormal AllocationStatus = "" // Normal allocation (empty for backward compat) + StatusExternal AllocationStatus = "external" // External process using this port +) + // AllocationInfo represents a single port allocation entry. type AllocationInfo struct { - Directory string `yaml:"directory"` - AssignedAt time.Time `yaml:"assigned_at"` - LastUsedAt time.Time `yaml:"last_used_at,omitempty"` - Locked bool `yaml:"locked,omitempty"` - ProcessName string `yaml:"process_name,omitempty"` - ContainerID string `yaml:"container_id,omitempty"` - Name string `yaml:"name,omitempty"` + Directory string `yaml:"directory"` + AssignedAt time.Time `yaml:"assigned_at"` + LastUsedAt time.Time `yaml:"last_used_at,omitempty"` + Locked bool `yaml:"locked,omitempty"` + ProcessName string `yaml:"process_name,omitempty"` + ContainerID string `yaml:"container_id,omitempty"` + Name string `yaml:"name,omitempty"` + Status AllocationStatus `yaml:"status,omitempty"` // StatusNormal or StatusExternal + LockedAt time.Time `yaml:"locked_at,omitempty"` // Time when port was locked + ExternalPID int `yaml:"external_pid,omitempty"` // PID of external process (0 = unknown) + ExternalUser string `yaml:"external_user,omitempty"` // User of external process + ExternalProcessName string `yaml:"external_process_name,omitempty"` // Name of external process } // Store is the root structure for the allocations file. @@ -43,14 +60,38 @@ type file struct { // Allocation represents a single port allocation (for external use). type Allocation struct { - Port int - Directory string - AssignedAt time.Time - LastUsedAt time.Time - Locked bool - ProcessName string - ContainerID string - Name string + Port int + Directory string + AssignedAt time.Time + LastUsedAt time.Time + Locked bool + ProcessName string + ContainerID string + Name string + Status AllocationStatus // StatusNormal or StatusExternal + LockedAt time.Time // Time when port was locked + ExternalPID int // PID of external process (0 = unknown) + ExternalUser string // User of external process + ExternalProcessName string // Name of external process +} + +// toAllocation converts AllocationInfo to Allocation with the given port number. +func (info *AllocationInfo) toAllocation(port int) *Allocation { + return &Allocation{ + Port: port, + Directory: info.Directory, + AssignedAt: info.AssignedAt, + LastUsedAt: info.LastUsedAt, + Locked: info.Locked, + ProcessName: info.ProcessName, + ContainerID: info.ContainerID, + Name: info.Name, + Status: info.Status, + LockedAt: info.LockedAt, + ExternalPID: info.ExternalPID, + ExternalUser: info.ExternalUser, + ExternalProcessName: info.ExternalProcessName, + } } // NewStore creates an empty store. @@ -269,16 +310,7 @@ func (s *Store) FindByDirectory(dir string) *Allocation { return nil } - return &Allocation{ - Port: bestPort, - Directory: bestInfo.Directory, - AssignedAt: bestInfo.AssignedAt, - LastUsedAt: bestInfo.LastUsedAt, - Locked: bestInfo.Locked, - ProcessName: bestInfo.ProcessName, - ContainerID: bestInfo.ContainerID, - Name: bestInfo.Name, - } + return bestInfo.toAllocation(bestPort) } // FindByPort returns the allocation for a given port, or nil if not found. @@ -287,16 +319,7 @@ func (s *Store) FindByPort(port int) *Allocation { if info == nil { return nil } - return &Allocation{ - Port: port, - Directory: info.Directory, - AssignedAt: info.AssignedAt, - LastUsedAt: info.LastUsedAt, - Locked: info.Locked, - ProcessName: info.ProcessName, - ContainerID: info.ContainerID, - Name: info.Name, - } + return info.toAllocation(port) } // PortChecker is a function that checks if a port is free. @@ -480,7 +503,7 @@ func (s *Store) AddAllocationForScan(dir string, port int, processName, containe // SetUnknownPortAllocation adds an allocation for a busy port with unknown ownership. func (s *Store) SetUnknownPortAllocation(port int, processName string) { now := time.Now().UTC() - dir := fmt.Sprintf("(unknown:%d)", port) + dir := fmt.Sprintf(UnknownDirectoryFormat, port) s.Allocations[port] = &AllocationInfo{ Directory: dir, @@ -507,16 +530,7 @@ func (s *Store) SortedByPort() []Allocation { var result []Allocation for port, info := range s.Allocations { if info != nil { - result = append(result, Allocation{ - Port: port, - Directory: info.Directory, - AssignedAt: info.AssignedAt, - LastUsedAt: info.LastUsedAt, - Locked: info.Locked, - ProcessName: info.ProcessName, - ContainerID: info.ContainerID, - Name: info.Name, - }) + result = append(result, *info.toAllocation(port)) } } @@ -533,16 +547,7 @@ func (s *Store) RemoveByDirectory(dir string) (*Allocation, bool) { dir = filepath.Clean(dir) for port, info := range s.Allocations { if info != nil && info.Directory == dir { - removed := &Allocation{ - Port: port, - Directory: info.Directory, - AssignedAt: info.AssignedAt, - LastUsedAt: info.LastUsedAt, - Locked: info.Locked, - ProcessName: info.ProcessName, - ContainerID: info.ContainerID, - Name: info.Name, - } + removed := info.toAllocation(port) delete(s.Allocations, port) logger.Log(logger.AllocDelete, logger.Field("port", port), logger.Field("dir", dir)) return removed, true @@ -639,6 +644,9 @@ func (s *Store) SetLocked(dir string, locked bool) bool { for port, info := range s.Allocations { if info != nil && info.Directory == dir { info.Locked = locked + if locked { + info.LockedAt = time.Now().UTC() + } s.Allocations[port] = info logger.Log(logger.AllocLock, logger.Field("port", port), logger.Field("locked", locked)) return true @@ -652,6 +660,9 @@ func (s *Store) SetLocked(dir string, locked bool) bool { func (s *Store) SetLockedByPort(port int, locked bool) bool { if info := s.Allocations[port]; info != nil { info.Locked = locked + if locked { + info.LockedAt = time.Now().UTC() + } logger.Log(logger.AllocLock, logger.Field("port", port), logger.Field("locked", locked)) return true } @@ -761,16 +772,7 @@ func (s *Store) FindByDirectoryAndName(dir string, name string) *Allocation { return nil } - return &Allocation{ - Port: bestPort, - Directory: bestInfo.Directory, - AssignedAt: bestInfo.AssignedAt, - LastUsedAt: bestInfo.LastUsedAt, - Locked: bestInfo.Locked, - ProcessName: bestInfo.ProcessName, - ContainerID: bestInfo.ContainerID, - Name: bestInfo.Name, - } + return bestInfo.toAllocation(bestPort) } // FindByDirectoryAndNameWithPriority returns the best allocation for a given directory and name. @@ -886,16 +888,7 @@ func (s *Store) RemoveByDirectoryAndName(dir string, name string) (*Allocation, name = normalizeName(name) for port, info := range s.Allocations { if info != nil && info.Directory == dir && info.Name == name { - removed := &Allocation{ - Port: port, - Directory: info.Directory, - AssignedAt: info.AssignedAt, - LastUsedAt: info.LastUsedAt, - Locked: info.Locked, - ProcessName: info.ProcessName, - ContainerID: info.ContainerID, - Name: info.Name, - } + removed := info.toAllocation(port) delete(s.Allocations, port) logger.Log(logger.AllocDelete, logger.Field("port", port), logger.Field("dir", dir), logger.Field("name", name)) return removed, true @@ -943,6 +936,9 @@ func (s *Store) SetLockedByDirectoryAndName(dir string, name string, locked bool for port, info := range s.Allocations { if info != nil && info.Directory == dir && info.Name == name { info.Locked = locked + if locked { + info.LockedAt = time.Now().UTC() + } logger.Log(logger.AllocLock, logger.Field("port", port), logger.Field("locked", locked), logger.Field("name", name)) return true } @@ -962,6 +958,9 @@ func (s *Store) SetLockedByPortAndName(port int, name string, locked bool) bool return false } info.Locked = locked + if locked { + info.LockedAt = time.Now().UTC() + } logger.Log(logger.AllocLock, logger.Field("port", port), logger.Field("locked", locked), logger.Field("name", name)) return true } @@ -991,3 +990,113 @@ func (s *Store) UnlockOtherLockedPorts(dir string, name string, exceptPort int) } return count } + +// SetExternalAllocation registers a port as used by an external process. +// This is used when a port is already in use by another directory/process. +// The allocation is marked with Status="external" and stores process information. +func (s *Store) SetExternalAllocation(port int, pid int, user, processName, cwd string) { + now := time.Now().UTC() + + existing := s.Allocations[port] + if existing != nil { + // Update existing allocation to external status + existing.Status = StatusExternal + existing.LastUsedAt = now + existing.ExternalPID = pid + existing.ExternalUser = user + existing.ExternalProcessName = processName + // Keep existing directory if any, otherwise use process cwd + if existing.Directory == "" || existing.Directory == fmt.Sprintf(UnknownDirectoryFormat, port) { + if cwd != "" { + existing.Directory = cwd + } + } + logger.Log(logger.AllocExternal, + logger.Field("port", port), + logger.Field("dir", existing.Directory), + logger.Field("pid", pid), + logger.Field("user", user), + logger.Field("process", processName), + logger.Field("action", "update")) + return + } + + // Create new external allocation + dir := cwd + if dir == "" { + dir = fmt.Sprintf(UnknownDirectoryFormat, port) + } + + s.Allocations[port] = &AllocationInfo{ + Directory: dir, + AssignedAt: now, + LastUsedAt: now, + Status: StatusExternal, + ExternalPID: pid, + ExternalUser: user, + ExternalProcessName: processName, + Name: "main", + } + logger.Log(logger.AllocExternal, + logger.Field("port", port), + logger.Field("dir", dir), + logger.Field("pid", pid), + logger.Field("user", user), + logger.Field("process", processName), + logger.Field("action", "create")) +} + +// RefreshExternalAllocations removes stale external allocations (ports that are now free). +// Updates LastUsedAt for allocations that are still active. +// Returns the count of removed allocations. +// Panics if isPortFree is nil (programming error). +func (s *Store) RefreshExternalAllocations(isPortFree PortChecker) int { + if isPortFree == nil { + panic("RefreshExternalAllocations: isPortFree function cannot be nil") + } + + var removedPorts []int + var updatedPorts []int + + for port, info := range s.Allocations { + if info == nil || info.Status != StatusExternal { + continue + } + + if isPortFree(port) { + // Port is now free - remove the external allocation + removedPorts = append(removedPorts, port) + } else { + // Port is still busy - update LastUsedAt + info.LastUsedAt = time.Now().UTC() + updatedPorts = append(updatedPorts, port) + } + } + + // Remove stale allocations + for _, port := range removedPorts { + info := s.Allocations[port] + logger.Log(logger.AllocDelete, + logger.Field("port", port), + logger.Field("dir", info.Directory), + logger.Field("reason", "stale_external")) + delete(s.Allocations, port) + } + + // Log updated allocations + for _, port := range updatedPorts { + info := s.Allocations[port] + logger.Log(logger.AllocUpdate, + logger.Field("port", port), + logger.Field("dir", info.Directory), + logger.Field("reason", "external_still_active")) + } + + if len(removedPorts) > 0 { + logger.Log(logger.AllocRefresh, + logger.Field("removed", len(removedPorts)), + logger.Field("updated", len(updatedPorts))) + } + + return len(removedPorts) +} diff --git a/internal/allocations/allocations_test.go b/internal/allocations/allocations_test.go index a9414b4..136327f 100644 --- a/internal/allocations/allocations_test.go +++ b/internal/allocations/allocations_test.go @@ -2227,6 +2227,137 @@ func TestSortedByPort_IncludesName(t *testing.T) { } } +// Tests for external allocations (issue #73) + +func TestSetExternalAllocation_New(t *testing.T) { + store := NewStore() + + store.SetExternalAllocation(3000, 12345, "user1", "python", "/home/user/other-project") + + if len(store.Allocations) != 1 { + t.Fatalf("expected 1 allocation, got %d", len(store.Allocations)) + } + + info := store.Allocations[3000] + if info == nil { + t.Fatal("expected allocation for port 3000") + } + if info.Status != StatusExternal { + t.Errorf("expected status 'external', got %q", info.Status) + } + if info.Directory != "/home/user/other-project" { + t.Errorf("expected dir /home/user/other-project, got %s", info.Directory) + } + if info.ExternalPID != 12345 { + t.Errorf("expected ExternalPID 12345, got %d", info.ExternalPID) + } + if info.ExternalUser != "user1" { + t.Errorf("expected ExternalUser 'user1', got %q", info.ExternalUser) + } + if info.ExternalProcessName != "python" { + t.Errorf("expected ExternalProcessName 'python', got %q", info.ExternalProcessName) + } + if info.Name != "main" { + t.Errorf("expected name 'main', got %q", info.Name) + } +} + +func TestSetExternalAllocation_UpdatesExisting(t *testing.T) { + store := NewStore() + store.Allocations[3000] = &AllocationInfo{ + Directory: "/home/user/project", + Name: "main", + ProcessName: "node", + AssignedAt: time.Now().Add(-1 * time.Hour), + } + + store.SetExternalAllocation(3000, 54321, "user2", "ruby", "/home/user/new-project") + + info := store.Allocations[3000] + if info == nil { + t.Fatal("expected allocation for port 3000") + } + if info.Status != StatusExternal { + t.Errorf("expected status 'external', got %q", info.Status) + } + // Directory should be preserved (not replaced) + if info.Directory != "/home/user/project" { + t.Errorf("expected original directory, got %s", info.Directory) + } + if info.ExternalPID != 54321 { + t.Errorf("expected ExternalPID 54321, got %d", info.ExternalPID) + } + if info.ExternalUser != "user2" { + t.Errorf("expected ExternalUser 'user2', got %q", info.ExternalUser) + } + if info.ExternalProcessName != "ruby" { + t.Errorf("expected ExternalProcessName 'ruby', got %q", info.ExternalProcessName) + } +} + +func TestSetExternalAllocation_SetsDirectoryWhenEmpty(t *testing.T) { + store := NewStore() + + // Create allocation with unknown directory + store.SetExternalAllocation(3007, 12345, "user1", "python", "") + + info := store.Allocations[3007] + if info == nil { + t.Fatal("expected allocation for port 3007") + } + if info.Directory != "(unknown:3007)" { + t.Errorf("expected directory (unknown:3007), got %s", info.Directory) + } +} + +func TestRefreshExternalAllocations_RemovesStale(t *testing.T) { + store := NewStore() + now := time.Now().UTC() + + // Add external allocations + store.Allocations[3000] = &AllocationInfo{ + Directory: "/home/user/project-a", + Status: StatusExternal, + ExternalPID: 12345, + ExternalUser: "user1", + ExternalProcessName: "python", + AssignedAt: now.Add(-1 * time.Hour), + LastUsedAt: now.Add(-1 * time.Hour), + Name: "main", + } + store.Allocations[3001] = &AllocationInfo{ + Directory: "/home/user/project-b", + Status: StatusExternal, + ExternalPID: 54321, + ExternalUser: "user2", + ExternalProcessName: "node", + AssignedAt: now.Add(-1 * time.Hour), + LastUsedAt: now.Add(-1 * time.Hour), + Name: "main", + } + + // Port checker: 3000 is free (stale), 3001 is busy (still active) + portChecker := func(port int) bool { + return port == 3000 // 3000 is free, 3001 is busy + } + + removed := store.RefreshExternalAllocations(portChecker) + + if removed != 1 { + t.Errorf("expected 1 removed, got %d", removed) + } + + // Port 3000 should be removed (stale external) + if store.Allocations[3000] != nil { + t.Error("port 3000 should be removed (stale external)") + } + + // Port 3001 should be preserved (still active) + if store.Allocations[3001] == nil { + t.Error("port 3001 should be preserved (still active)") + } +} + // Tests for issue #75: Locked ports should never be automatically deleted func TestRemoveExpired_PreservesLockedPorts(t *testing.T) { @@ -2696,3 +2827,133 @@ func TestFindByDirectoryAndNameWithPriority_NoMatchingAllocations(t *testing.T) t.Errorf("expected nil for non-matching dir, got port %d", result.Port) } } + +func TestRefreshExternalAllocations_KeepsActive(t *testing.T) { + store := NewStore() + now := time.Now().UTC() + + store.Allocations[3000] = &AllocationInfo{ + Directory: "/home/user/project-a", + Status: StatusExternal, + ExternalPID: 12345, + ExternalUser: "user1", + ExternalProcessName: "python", + AssignedAt: now.Add(-1 * time.Hour), + LastUsedAt: now.Add(-1 * time.Hour), + Name: "main", + } + + // Port is still busy + portChecker := func(port int) bool { return false } + + removed := store.RefreshExternalAllocations(portChecker) + + if removed != 0 { + t.Errorf("expected 0 removed, got %d", removed) + } + + if store.Allocations[3000] == nil { + t.Error("port 3000 should still exist") + } +} + +func TestRefreshExternalAllocations_SkipsNonExternal(t *testing.T) { + store := NewStore() + + // Regular allocation (not external) + store.Allocations[3000] = &AllocationInfo{ + Directory: "/home/user/project", + Name: "main", + AssignedAt: time.Now(), + Status: "", // Empty status (not external) + } + + portChecker := func(port int) bool { return true } + + removed := store.RefreshExternalAllocations(portChecker) + + if removed != 0 { + t.Errorf("expected 0 removed (non-external should be skipped), got %d", removed) + } + + // Regular allocation should not be affected + if store.Allocations[3000] == nil { + t.Error("regular allocation should not be affected") + } +} + +func TestRefreshExternalAllocations_NilPortChecker_Panics(t *testing.T) { + store := NewStore() + + defer func() { + if r := recover(); r == nil { + t.Error("expected panic with nil PortChecker, but did not panic") + } + }() + + store.RefreshExternalAllocations(nil) +} + +func TestFindByPort_IncludesExternalFields(t *testing.T) { + store := NewStore() + store.Allocations[3000] = &AllocationInfo{ + Directory: "/home/user/project", + Status: StatusExternal, + ExternalPID: 12345, + ExternalUser: "user1", + ExternalProcessName: "python", + Name: "main", + } + + result := store.FindByPort(3000) + if result == nil { + t.Fatal("expected allocation, got nil") + } + if result.Status != StatusExternal { + t.Errorf("expected Status 'external', got %q", result.Status) + } + if result.ExternalPID != 12345 { + t.Errorf("expected ExternalPID 12345, got %d", result.ExternalPID) + } + if result.ExternalUser != "user1" { + t.Errorf("expected ExternalUser 'user1', got %q", result.ExternalUser) + } + if result.ExternalProcessName != "python" { + t.Errorf("expected ExternalProcessName 'python', got %q", result.ExternalProcessName) + } +} + +func TestSortedByPort_IncludesExternalFields(t *testing.T) { + store := NewStore() + store.Allocations[3000] = &AllocationInfo{ + Directory: "/home/user/project-a", + Status: StatusExternal, + ExternalPID: 12345, + ExternalUser: "user1", + ExternalProcessName: "python", + Name: "main", + } + store.Allocations[3001] = &AllocationInfo{ + Directory: "/home/user/project-b", + Name: "web", + } + + sorted := store.SortedByPort() + + if len(sorted) != 2 { + t.Fatalf("expected 2 allocations, got %d", len(sorted)) + } + + // First should be external + if sorted[0].Status != StatusExternal { + t.Errorf("expected Status 'external' for port 3000, got %q", sorted[0].Status) + } + if sorted[0].ExternalPID != 12345 { + t.Errorf("expected ExternalPID 12345 for port 3000, got %d", sorted[0].ExternalPID) + } + + // Second should be regular + if sorted[1].Status != "" { + t.Errorf("expected empty Status for port 3001, got %q", sorted[1].Status) + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 6755465..2c7ecaf 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -18,6 +18,8 @@ const ( AllocDelete = "ALLOC_DELETE" AllocDeleteAll = "ALLOC_DELETE_ALL" AllocExpire = "ALLOC_EXPIRE" + AllocExternal = "ALLOC_EXTERNAL" // For registering external ports + AllocRefresh = "ALLOC_REFRESH" // For refresh operations ) // Logger handles writing events to a log file.