Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions cmd/shell/autoclose.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,30 @@ func parseCloseDeadline(value string, now time.Time) (time.Time, error) {
return time.Time{}, fmt.Errorf("invalid auto-close value %q (try 5m, 2h, 3d, 1w, 2mo, or an ISO date)", value)
}

// processCloseDeadline starts relative durations when the shared process is
// ready to launch. Network/session setup must not consume a duration such as
// --auto-close 5s. Absolute dates retain their original wall-clock meaning.
func processCloseDeadline(value string, parsedDeadline, processStart time.Time) time.Time {
value = strings.TrimSpace(value)
if strings.HasPrefix(strings.ToLower(value), "in ") {
value = strings.TrimSpace(value[3:])
}
if deadline, ok := parseRelativeDeadline(value, processStart); ok {
return deadline
}
return parsedDeadline
}

// boundedCloseDeadline prevents the local process from outliving a deadline
// the relay has already declared. A zero requested deadline still means the
// task itself controls its lifetime.
func boundedCloseDeadline(requested, sessionExpiry time.Time) time.Time {
if requested.IsZero() || sessionExpiry.IsZero() || !sessionExpiry.Before(requested) {
return requested
}
return sessionExpiry
}

func parseRelativeDeadline(value string, now time.Time) (time.Time, bool) {
remainder := strings.ReplaceAll(strings.TrimSpace(value), " ", "")
if remainder == "" {
Expand Down
42 changes: 42 additions & 0 deletions cmd/shell/autoclose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,48 @@ func TestParseCloseDeadlineRejectsInvalidValues(t *testing.T) {
}
}

func TestProcessCloseDeadlineStartsRelativeDurationAfterSetup(t *testing.T) {
parsedAt := time.Date(2026, time.August, 19, 23, 40, 0, 0, time.UTC)
processStart := parsedAt.Add(12 * time.Second)
parsedDeadline, err := parseCloseDeadline("25ms", parsedAt)
if err != nil {
t.Fatal(err)
}

got := processCloseDeadline("25ms", parsedDeadline, processStart)
want := processStart.Add(25 * time.Millisecond)
if !got.Equal(want) {
t.Fatalf("processCloseDeadline() = %v, want %v", got, want)
}
}

func TestProcessCloseDeadlinePreservesAbsoluteTime(t *testing.T) {
parsedAt := time.Date(2026, time.August, 19, 23, 40, 0, 0, time.UTC)
processStart := parsedAt.Add(12 * time.Second)
parsedDeadline := parsedAt.Add(time.Hour)

got := processCloseDeadline("2026-08-20T00:40:00Z", parsedDeadline, processStart)
if !got.Equal(parsedDeadline) {
t.Fatalf("processCloseDeadline() = %v, want %v", got, parsedDeadline)
}
}

func TestBoundedCloseDeadlineUsesRelayExpiry(t *testing.T) {
now := time.Date(2026, time.August, 19, 23, 40, 0, 0, time.UTC)
sessionExpiry := now.Add(12 * time.Hour)
requested := now.Add(30 * 24 * time.Hour)

if got := boundedCloseDeadline(requested, sessionExpiry); !got.Equal(sessionExpiry) {
t.Fatalf("boundedCloseDeadline() = %v, want %v", got, sessionExpiry)
}
if got := boundedCloseDeadline(now.Add(time.Hour), sessionExpiry); !got.Equal(now.Add(time.Hour)) {
t.Fatalf("boundedCloseDeadline() shortened a valid deadline to %v", got)
}
if got := boundedCloseDeadline(time.Time{}, sessionExpiry); !got.IsZero() {
t.Fatalf("boundedCloseDeadline() changed task-bound lifetime to %v", got)
}
}

func TestNormalizeAutoCloseArguments(t *testing.T) {
now := time.Date(2026, time.August, 19, 23, 40, 0, 0, time.UTC)
tests := []struct {
Expand Down
36 changes: 23 additions & 13 deletions cmd/shell/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func run(arguments []string, stdout, stderr io.Writer) int {
fmt.Fprintln(stderr, "shell: --e2ee and --no-e2ee cannot be used together")
return 2
}
closeDeadline, err := parseCloseDeadline(autoClose.value, now)
parsedCloseDeadline, err := parseCloseDeadline(autoClose.value, now)
if err != nil {
fmt.Fprintf(stderr, "shell: %v\n", err)
return 2
Expand Down Expand Up @@ -124,20 +124,11 @@ func run(arguments []string, stdout, stderr io.Writer) int {
signalContext, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
defer stopSignals()

var processContext context.Context
var cancelProcess context.CancelFunc
if closeDeadline.IsZero() {
processContext, cancelProcess = context.WithCancel(signalContext)
} else {
processContext, cancelProcess = context.WithDeadline(signalContext, closeDeadline)
}
defer cancelProcess()

client := api.NewClient(strings.TrimRight(*server, "/"), "shell/"+version)
var session api.Session
if *persistentState != "" {
session, password, err = preparePersistentSession(
processContext, client, *persistentState, filepath.Base(command[0]), *readOnly, true, password,
signalContext, client, *persistentState, filepath.Base(command[0]), *readOnly, true, password,
)
} else {
if encrypted && password == "" {
Expand All @@ -150,7 +141,7 @@ func run(arguments []string, stdout, stderr io.Writer) int {
frameCipher, encryptionFragment, err = e2ee.Generate(password)
}
if err == nil {
session, err = client.CreateSession(processContext, filepath.Base(command[0]), *readOnly, encrypted, false)
session, err = client.CreateSession(signalContext, filepath.Base(command[0]), *readOnly, encrypted, false)
}
session.Cipher = frameCipher
session.ShareURL += encryptionFragment
Expand All @@ -162,6 +153,25 @@ func run(arguments []string, stdout, stderr io.Writer) int {
return 1
}

processStartedAt := time.Now()
closeDeadline := processCloseDeadline(autoClose.value, parsedCloseDeadline, processStartedAt)
closeDeadline = boundedCloseDeadline(closeDeadline, session.ExpiresAt)
if !closeDeadline.IsZero() && !closeDeadline.After(processStartedAt) {
err = fmt.Errorf("auto-close deadline elapsed before the process could start")
sendBackgroundResult(backgroundLaunchResult{OK: false, Error: err.Error()})
fmt.Fprintf(stderr, "shell: %v\n", err)
return 1
}

var processContext context.Context
var cancelProcess context.CancelFunc
if closeDeadline.IsZero() {
processContext, cancelProcess = context.WithCancel(signalContext)
} else {
processContext, cancelProcess = context.WithDeadline(signalContext, closeDeadline)
}
defer cancelProcess()

var closesAt *time.Time
if !closeDeadline.IsZero() {
deadline := closeDeadline
Expand All @@ -176,7 +186,7 @@ func run(arguments []string, stdout, stderr io.Writer) int {
Persistent: session.Persistent,
Command: displayCommand(launch.DisplayArguments),
PID: os.Getpid(),
StartedAt: now,
StartedAt: processStartedAt,
ClosesAt: closesAt,
})
if controlError != nil {
Expand Down
Loading