feat(snapshot): restore allowlisted foreground processes
- commit
- 07ec9d9d3d1fd9ed16fe7474a1479a4a8a3e4f38
- author
- brice · · 2026-07-06 08:06 UTC
- parent
- 2e3ea8b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +176 -34
M README.md +7 -2
| @@ -35,9 +35,9 @@ vev --help show help (aliases: -h, help) | |||
| 35 | 35 | vev --version show version (alias: version) | |
| 36 | 36 | ``` | |
| 37 | 37 | ||
| 38 | - | Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. The daemon starts on first use and exits when no sessions remain. | |
| 38 | + | Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes. | |
| 39 | 39 | ||
| 40 | - | Only one client is attached to a session at a time; attaching again displaces the old client. | |
| 40 | + | The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client. | |
| 41 | 41 | ||
| 42 | 42 | ## Configuration | |
| 43 | 43 | ||
| @@ -64,6 +64,11 @@ switch-tab-7 = alt+7 | |||
| 64 | 64 | switch-tab-8 = alt+8 | |
| 65 | 65 | switch-tab-9 = alt+9 | |
| 66 | 66 | ||
| 67 | + | # Named session snapshots restore layout, cwd, visible content, scrollback, | |
| 68 | + | # and foreground processes from this allowlist. Omit the key for these defaults; | |
| 69 | + | # set it empty to disable process relaunch. | |
| 70 | + | snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode | |
| 71 | + | ||
| 67 | 72 | # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits. | |
| 68 | 73 | code.new-tab = CNT | |
| 69 | 74 | code.new-session = CNS | |
| @@ -35,9 +35,9 @@ vev --help show help (aliases: -h, help) | |||||
| 35 | vev --version show version (alias: version) | 35 | vev --version show version (alias: version) | ||
| 36 | ``` | 36 | ``` | ||
| 37 | 37 | ||||
| 38 | - | Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. The daemon starts on first use and exits when no sessions remain. | 38 | + | Ephemeral sessions get a number (`0`, `1`, `2`, ...) and die when you detach. Named sessions survive detach and can be resumed later. New and renamed session names must start with a letter or number and may contain only letters, numbers, dots, underscores, and dashes. |
| 39 | 39 | ||||
| 40 | - | Only one client is attached to a session at a time; attaching again displaces the old client. | 40 | + | The daemon starts on first use and exits when no sessions remain. Only one client is attached to a session at a time; attaching again displaces the old client. |
| 41 | 41 | ||||
| 42 | ## Configuration | 42 | ## Configuration | ||
| 43 | 43 | ||||
| @@ -64,6 +64,11 @@ switch-tab-7 = alt+7 | |||||
| 64 | switch-tab-8 = alt+8 | 64 | switch-tab-8 = alt+8 | ||
| 65 | switch-tab-9 = alt+9 | 65 | switch-tab-9 = alt+9 | ||
| 66 | 66 | ||||
| 67 | + | # Named session snapshots restore layout, cwd, visible content, scrollback, | |||
| 68 | + | # and foreground processes from this allowlist. Omit the key for these defaults; | |||
| 69 | + | # set it empty to disable process relaunch. | |||
| 70 | + | snapshot.restore_processes = vi,vim,nvim,emacs,man,less,more,tail,top,htop,btop,claude,codex,pi,opencode | |||
| 71 | + | ||||
| 67 | # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits. | 72 | # Command palette codes are trimmed, uppercased, then checked as 2-3 letters or digits. | ||
| 68 | code.new-tab = CNT | 73 | code.new-tab = CNT | ||
| 69 | code.new-session = CNS | 74 | code.new-session = CNS | ||
M internal/usecase/daemon/config.go +34 -3
| @@ -2,6 +2,7 @@ package daemon | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "maps" | |
| 5 | 6 | "regexp" | |
| 6 | 7 | "sort" | |
| 7 | 8 | "strings" | |
| @@ -21,8 +22,10 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||
| 21 | 22 | d.logConfigWarning(warning) | |
| 22 | 23 | } | |
| 23 | 24 | overrides := d.buildCodeOverrides(cfg.Codes) | |
| 25 | + | allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot) | |
| 24 | 26 | d.bindings.Store(bindings) | |
| 25 | 27 | d.codeOverrides.Store(&overrides) | |
| 28 | + | d.restoreProcessAllowlist.Store(&allowlist) | |
| 26 | 29 | d.themeMode.Store(uint32(cfg.Theme)) | |
| 27 | 30 | d.reapplyThemeAllSessions() | |
| 28 | 31 | d.repaintAllAttachedClients() | |
| @@ -56,9 +59,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str | |||
| 56 | 59 | // *different* slug's override — that must be re-checked regardless of | |
| 57 | 60 | // where the two slugs fall in sort order. | |
| 58 | 61 | accepted := make(map[string]string, len(desired)) | |
| 59 | - | for slug, code := range desired { | |
| 60 | - | accepted[slug] = code | |
| 61 | - | } | |
| 62 | + | maps.Copy(accepted, desired) | |
| 62 | 63 | effectiveCode := func(slug string) string { | |
| 63 | 64 | if code, ok := accepted[slug]; ok { | |
| 64 | 65 | return code | |
| @@ -122,6 +123,36 @@ func sortedKeys(values map[string]string) []string { | |||
| 122 | 123 | return keys | |
| 123 | 124 | } | |
| 124 | 125 | ||
| 126 | + | func restoreProcessAllowlistFromConfig(cfg domain.SnapshotConfig) map[string]struct{} { | |
| 127 | + | if cfg.RestoreProcessesSet { | |
| 128 | + | return buildRestoreProcessAllowlist(cfg.RestoreProcesses) | |
| 129 | + | } | |
| 130 | + | if len(cfg.RestoreProcesses) > 0 { | |
| 131 | + | return buildRestoreProcessAllowlist(cfg.RestoreProcesses) | |
| 132 | + | } | |
| 133 | + | return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func buildRestoreProcessAllowlist(values []string) map[string]struct{} { | |
| 137 | + | allow := make(map[string]struct{}, len(values)) | |
| 138 | + | for _, value := range values { | |
| 139 | + | value = strings.TrimSpace(value) | |
| 140 | + | if value == "" { | |
| 141 | + | continue | |
| 142 | + | } | |
| 143 | + | allow[value] = struct{}{} | |
| 144 | + | } | |
| 145 | + | return allow | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} { | |
| 149 | + | allow := d.restoreProcessAllowlist.Load() | |
| 150 | + | if allow == nil { | |
| 151 | + | return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses) | |
| 152 | + | } | |
| 153 | + | return *allow | |
| 154 | + | } | |
| 155 | + | ||
| 125 | 156 | func (d *Daemon) logConfigWarning(w domain.Warning) { | |
| 126 | 157 | if w.Line > 0 { | |
| 127 | 158 | d.log.Warn("config warning", "line", w.Line, "msg", w.Msg) | |
| @@ -2,6 +2,7 @@ package daemon | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | + | "maps" | |||
| 5 | "regexp" | 6 | "regexp" | ||
| 6 | "sort" | 7 | "sort" | ||
| 7 | "strings" | 8 | "strings" | ||
| @@ -21,8 +22,10 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) { | |||||
| 21 | d.logConfigWarning(warning) | 22 | d.logConfigWarning(warning) | ||
| 22 | } | 23 | } | ||
| 23 | overrides := d.buildCodeOverrides(cfg.Codes) | 24 | overrides := d.buildCodeOverrides(cfg.Codes) | ||
| 25 | + | allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot) | |||
| 24 | d.bindings.Store(bindings) | 26 | d.bindings.Store(bindings) | ||
| 25 | d.codeOverrides.Store(&overrides) | 27 | d.codeOverrides.Store(&overrides) | ||
| 28 | + | d.restoreProcessAllowlist.Store(&allowlist) | |||
| 26 | d.themeMode.Store(uint32(cfg.Theme)) | 29 | d.themeMode.Store(uint32(cfg.Theme)) | ||
| 27 | d.reapplyThemeAllSessions() | 30 | d.reapplyThemeAllSessions() | ||
| 28 | d.repaintAllAttachedClients() | 31 | d.repaintAllAttachedClients() | ||
| @@ -56,9 +59,7 @@ func (d *Daemon) buildCodeOverrides(configured map[string]string) map[string]str | |||||
| 56 | // *different* slug's override — that must be re-checked regardless of | 59 | // *different* slug's override — that must be re-checked regardless of | ||
| 57 | // where the two slugs fall in sort order. | 60 | // where the two slugs fall in sort order. | ||
| 58 | accepted := make(map[string]string, len(desired)) | 61 | accepted := make(map[string]string, len(desired)) | ||
| 59 | - | for slug, code := range desired { | 62 | + | maps.Copy(accepted, desired) |
| 60 | - | accepted[slug] = code | |||
| 61 | - | } | |||
| 62 | effectiveCode := func(slug string) string { | 63 | effectiveCode := func(slug string) string { | ||
| 63 | if code, ok := accepted[slug]; ok { | 64 | if code, ok := accepted[slug]; ok { | ||
| 64 | return code | 65 | return code | ||
| @@ -122,6 +123,36 @@ func sortedKeys(values map[string]string) []string { | |||||
| 122 | return keys | 123 | return keys | ||
| 123 | } | 124 | } | ||
| 124 | 125 | ||||
| 126 | + | func restoreProcessAllowlistFromConfig(cfg domain.SnapshotConfig) map[string]struct{} { | |||
| 127 | + | if cfg.RestoreProcessesSet { | |||
| 128 | + | return buildRestoreProcessAllowlist(cfg.RestoreProcesses) | |||
| 129 | + | } | |||
| 130 | + | if len(cfg.RestoreProcesses) > 0 { | |||
| 131 | + | return buildRestoreProcessAllowlist(cfg.RestoreProcesses) | |||
| 132 | + | } | |||
| 133 | + | return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func buildRestoreProcessAllowlist(values []string) map[string]struct{} { | |||
| 137 | + | allow := make(map[string]struct{}, len(values)) | |||
| 138 | + | for _, value := range values { | |||
| 139 | + | value = strings.TrimSpace(value) | |||
| 140 | + | if value == "" { | |||
| 141 | + | continue | |||
| 142 | + | } | |||
| 143 | + | allow[value] = struct{}{} | |||
| 144 | + | } | |||
| 145 | + | return allow | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} { | |||
| 149 | + | allow := d.restoreProcessAllowlist.Load() | |||
| 150 | + | if allow == nil { | |||
| 151 | + | return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses) | |||
| 152 | + | } | |||
| 153 | + | return *allow | |||
| 154 | + | } | |||
| 155 | + | ||||
| 125 | func (d *Daemon) logConfigWarning(w domain.Warning) { | 156 | func (d *Daemon) logConfigWarning(w domain.Warning) { | ||
| 126 | if w.Line > 0 { | 157 | if w.Line > 0 { | ||
| 127 | d.log.Warn("config warning", "line", w.Line, "msg", w.Msg) | 158 | d.log.Warn("config warning", "line", w.Line, "msg", w.Msg) | ||
M internal/usecase/daemon/config_test.go +16 -0
| @@ -49,6 +49,22 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||
| 49 | 49 | require.Equal(t, "NT", cmd.Code) | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) { | |
| 53 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 54 | + | ||
| 55 | + | _, ok := d.restoreProcessAllowlistSnapshot()["claude"] | |
| 56 | + | require.True(t, ok) | |
| 57 | + | ||
| 58 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}}) | |
| 59 | + | require.Empty(t, d.restoreProcessAllowlistSnapshot()) | |
| 60 | + | ||
| 61 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"less", "", " pi "}}}) | |
| 62 | + | allow := d.restoreProcessAllowlistSnapshot() | |
| 63 | + | require.Contains(t, allow, "less") | |
| 64 | + | require.Contains(t, allow, "pi") | |
| 65 | + | require.NotContains(t, allow, "") | |
| 66 | + | } | |
| 67 | + | ||
| 52 | 68 | func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { | |
| 53 | 69 | d := newTestDaemon(t, nil, stubClock{}) | |
| 54 | 70 | d.ApplyConfig(domain.Config{ | |
| @@ -49,6 +49,22 @@ func TestApplyConfigHotReloadSwapsBindingsAndCodes(t *testing.T) { | |||||
| 49 | require.Equal(t, "NT", cmd.Code) | 49 | require.Equal(t, "NT", cmd.Code) | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | + | func TestApplyConfigSnapshotRestoreProcesses(t *testing.T) { | |||
| 53 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 54 | + | ||||
| 55 | + | _, ok := d.restoreProcessAllowlistSnapshot()["claude"] | |||
| 56 | + | require.True(t, ok) | |||
| 57 | + | ||||
| 58 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true}}) | |||
| 59 | + | require.Empty(t, d.restoreProcessAllowlistSnapshot()) | |||
| 60 | + | ||||
| 61 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"less", "", " pi "}}}) | |||
| 62 | + | allow := d.restoreProcessAllowlistSnapshot() | |||
| 63 | + | require.Contains(t, allow, "less") | |||
| 64 | + | require.Contains(t, allow, "pi") | |||
| 65 | + | require.NotContains(t, allow, "") | |||
| 66 | + | } | |||
| 67 | + | ||||
| 52 | func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { | 68 | func TestPaletteCommandsApplyOverridesAndResolveByCode(t *testing.T) { | ||
| 53 | d := newTestDaemon(t, nil, stubClock{}) | 69 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 54 | d.ApplyConfig(domain.Config{ | 70 | d.ApplyConfig(domain.Config{ | ||
M internal/usecase/daemon/daemon.go +25 -20
| @@ -95,26 +95,27 @@ type Daemon struct { | |||
| 95 | 95 | paletteRecentMu sync.Mutex | |
| 96 | 96 | paletteRecent []string | |
| 97 | 97 | ||
| 98 | - | ptys ports.PTYFactory | |
| 99 | - | clock ports.Clock | |
| 100 | - | log *slog.Logger | |
| 101 | - | baseEnv []string | |
| 102 | - | shell string | |
| 103 | - | shellArgs []string | |
| 104 | - | persist *persist.Persister | |
| 105 | - | persistEnabled bool | |
| 106 | - | snaps ports.SnapshotStore | |
| 107 | - | snapsEnabled bool | |
| 108 | - | restoreDone chan struct{} | |
| 109 | - | restoreOnce sync.Once | |
| 110 | - | procCwd func(int) (string, error) | |
| 111 | - | procComm func(int) (string, error) | |
| 112 | - | procArgv func(int) ([]string, error) | |
| 113 | - | procGroupArgv func(int, int) ([]string, error) | |
| 114 | - | dirOrHome func(string) string | |
| 115 | - | bindings atomic.Pointer[keys.Bindings] | |
| 116 | - | codeOverrides atomic.Pointer[map[string]string] | |
| 117 | - | themeMode atomic.Uint32 | |
| 98 | + | ptys ports.PTYFactory | |
| 99 | + | clock ports.Clock | |
| 100 | + | log *slog.Logger | |
| 101 | + | baseEnv []string | |
| 102 | + | shell string | |
| 103 | + | shellArgs []string | |
| 104 | + | persist *persist.Persister | |
| 105 | + | persistEnabled bool | |
| 106 | + | snaps ports.SnapshotStore | |
| 107 | + | snapsEnabled bool | |
| 108 | + | restoreDone chan struct{} | |
| 109 | + | restoreOnce sync.Once | |
| 110 | + | procCwd func(int) (string, error) | |
| 111 | + | procComm func(int) (string, error) | |
| 112 | + | procArgv func(int) ([]string, error) | |
| 113 | + | procGroupArgv func(int, int) ([]string, error) | |
| 114 | + | dirOrHome func(string) string | |
| 115 | + | bindings atomic.Pointer[keys.Bindings] | |
| 116 | + | codeOverrides atomic.Pointer[map[string]string] | |
| 117 | + | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] | |
| 118 | + | themeMode atomic.Uint32 | |
| 118 | 119 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 119 | 120 | // (see clipboard.go); empty means use os.TempDir(). | |
| 120 | 121 | tempDir string | |
| @@ -271,6 +272,10 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 271 | 272 | empty := map[string]string{} | |
| 272 | 273 | d.codeOverrides.Store(&empty) | |
| 273 | 274 | } | |
| 275 | + | if d.restoreProcessAllowlist.Load() == nil { | |
| 276 | + | allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses) | |
| 277 | + | d.restoreProcessAllowlist.Store(&allow) | |
| 278 | + | } | |
| 274 | 279 | if records, err := d.persist.LoadAll(); err != nil { | |
| 275 | 280 | d.log.Warn("loading persisted sessions failed", "err", err) | |
| 276 | 281 | } else { | |
| @@ -95,26 +95,27 @@ type Daemon struct { | |||||
| 95 | paletteRecentMu sync.Mutex | 95 | paletteRecentMu sync.Mutex | ||
| 96 | paletteRecent []string | 96 | paletteRecent []string | ||
| 97 | 97 | ||||
| 98 | - | ptys ports.PTYFactory | 98 | + | ptys ports.PTYFactory |
| 99 | - | clock ports.Clock | 99 | + | clock ports.Clock |
| 100 | - | log *slog.Logger | 100 | + | log *slog.Logger |
| 101 | - | baseEnv []string | 101 | + | baseEnv []string |
| 102 | - | shell string | 102 | + | shell string |
| 103 | - | shellArgs []string | 103 | + | shellArgs []string |
| 104 | - | persist *persist.Persister | 104 | + | persist *persist.Persister |
| 105 | - | persistEnabled bool | 105 | + | persistEnabled bool |
| 106 | - | snaps ports.SnapshotStore | 106 | + | snaps ports.SnapshotStore |
| 107 | - | snapsEnabled bool | 107 | + | snapsEnabled bool |
| 108 | - | restoreDone chan struct{} | 108 | + | restoreDone chan struct{} |
| 109 | - | restoreOnce sync.Once | 109 | + | restoreOnce sync.Once |
| 110 | - | procCwd func(int) (string, error) | 110 | + | procCwd func(int) (string, error) |
| 111 | - | procComm func(int) (string, error) | 111 | + | procComm func(int) (string, error) |
| 112 | - | procArgv func(int) ([]string, error) | 112 | + | procArgv func(int) ([]string, error) |
| 113 | - | procGroupArgv func(int, int) ([]string, error) | 113 | + | procGroupArgv func(int, int) ([]string, error) |
| 114 | - | dirOrHome func(string) string | 114 | + | dirOrHome func(string) string |
| 115 | - | bindings atomic.Pointer[keys.Bindings] | 115 | + | bindings atomic.Pointer[keys.Bindings] |
| 116 | - | codeOverrides atomic.Pointer[map[string]string] | 116 | + | codeOverrides atomic.Pointer[map[string]string] |
| 117 | - | themeMode atomic.Uint32 | 117 | + | restoreProcessAllowlist atomic.Pointer[map[string]struct{}] |
| 118 | + | themeMode atomic.Uint32 | |||
| 118 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 119 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| 119 | // (see clipboard.go); empty means use os.TempDir(). | 120 | // (see clipboard.go); empty means use os.TempDir(). | ||
| 120 | tempDir string | 121 | tempDir string | ||
| @@ -271,6 +272,10 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 271 | empty := map[string]string{} | 272 | empty := map[string]string{} | ||
| 272 | d.codeOverrides.Store(&empty) | 273 | d.codeOverrides.Store(&empty) | ||
| 273 | } | 274 | } | ||
| 275 | + | if d.restoreProcessAllowlist.Load() == nil { | |||
| 276 | + | allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses) | |||
| 277 | + | d.restoreProcessAllowlist.Store(&allow) | |||
| 278 | + | } | |||
| 274 | if records, err := d.persist.LoadAll(); err != nil { | 279 | if records, err := d.persist.LoadAll(); err != nil { | ||
| 275 | d.log.Warn("loading persisted sessions failed", "err", err) | 280 | d.log.Warn("loading persisted sessions failed", "err", err) | ||
| 276 | } else { | 281 | } else { | ||
M internal/usecase/daemon/process_restore.go +1 -1
| @@ -81,7 +81,7 @@ func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) proc | |||
| 81 | 81 | switch strategy { | |
| 82 | 82 | case processStrategyPi: | |
| 83 | 83 | if id != "" { | |
| 84 | - | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true} | |
| 84 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--session-id", id}), Restore: true} | |
| 85 | 85 | } | |
| 86 | 86 | return processRestoreDecision{Command: "pi --continue", Restore: true} | |
| 87 | 87 | case processStrategyClaude: | |
| @@ -81,7 +81,7 @@ func planProcessRestore(proc *snapcodec.Process, allow map[string]struct{}) proc | |||||
| 81 | switch strategy { | 81 | switch strategy { | ||
| 82 | case processStrategyPi: | 82 | case processStrategyPi: | ||
| 83 | if id != "" { | 83 | if id != "" { | ||
| 84 | - | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--resume", id}), Restore: true} | 84 | + | return processRestoreDecision{Command: shellQuoteArgvMust([]string{"pi", "--session-id", id}), Restore: true} |
| 85 | } | 85 | } | ||
| 86 | return processRestoreDecision{Command: "pi --continue", Restore: true} | 86 | return processRestoreDecision{Command: "pi --continue", Restore: true} | ||
| 87 | case processStrategyClaude: | 87 | case processStrategyClaude: | ||
M internal/usecase/daemon/process_restore_test.go +1 -1
| @@ -37,7 +37,7 @@ func TestProcessRestorePlan(t *testing.T) { | |||
| 37 | 37 | name: "pi with ID resumes", | |
| 38 | 38 | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}}, | |
| 39 | 39 | restore: true, | |
| 40 | - | command: "pi --resume session-123", | |
| 40 | + | command: "pi --session-id session-123", | |
| 41 | 41 | }, | |
| 42 | 42 | { | |
| 43 | 43 | name: "pi no ID continues", | |
| @@ -37,7 +37,7 @@ func TestProcessRestorePlan(t *testing.T) { | |||||
| 37 | name: "pi with ID resumes", | 37 | name: "pi with ID resumes", | ||
| 38 | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}}, | 38 | proc: &snapcodec.Process{Argv: []string{"pi", "cli"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "session-123"}}, | ||
| 39 | restore: true, | 39 | restore: true, | ||
| 40 | - | command: "pi --resume session-123", | 40 | + | command: "pi --session-id session-123", |
| 41 | }, | 41 | }, | ||
| 42 | { | 42 | { | ||
| 43 | name: "pi no ID continues", | 43 | name: "pi no ID continues", | ||
M internal/usecase/daemon/snapshot.go +10 -0
| @@ -73,6 +73,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 73 | 73 | } | |
| 74 | 74 | cancel() | |
| 75 | 75 | } | |
| 76 | + | restoreCommands := make(map[layout.PaneID]string) | |
| 77 | + | allowlist := d.restoreProcessAllowlistSnapshot() | |
| 76 | 78 | for _, tabSnap := range snap.Tabs { | |
| 77 | 79 | if err := ctx.Err(); err != nil { | |
| 78 | 80 | closeOpened() | |
| @@ -108,6 +110,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 108 | 110 | tb.ctx, tb.cancel = context.WithCancel(sctx) | |
| 109 | 111 | opened = append(opened, tb) | |
| 110 | 112 | for _, paneSnap := range tabSnap.Panes { | |
| 113 | + | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | |
| 114 | + | restoreCommands[paneSnap.ID] = decision.Command | |
| 115 | + | } | |
| 111 | 116 | if err := ctx.Err(); err != nil { | |
| 112 | 117 | closeOpened() | |
| 113 | 118 | return err | |
| @@ -136,6 +141,11 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 136 | 141 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |
| 137 | 142 | p.rect = contentRect | |
| 138 | 143 | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | |
| 144 | + | if command := restoreCommands[paneSnap.ID]; command != "" { | |
| 145 | + | if _, err := pty.Write([]byte(command + "\n")); err != nil { | |
| 146 | + | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | |
| 147 | + | } | |
| 148 | + | } | |
| 139 | 149 | tb.panes[paneSnap.ID] = p | |
| 140 | 150 | } | |
| 141 | 151 | } | |
| @@ -73,6 +73,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 73 | } | 73 | } | ||
| 74 | cancel() | 74 | cancel() | ||
| 75 | } | 75 | } | ||
| 76 | + | restoreCommands := make(map[layout.PaneID]string) | |||
| 77 | + | allowlist := d.restoreProcessAllowlistSnapshot() | |||
| 76 | for _, tabSnap := range snap.Tabs { | 78 | for _, tabSnap := range snap.Tabs { | ||
| 77 | if err := ctx.Err(); err != nil { | 79 | if err := ctx.Err(); err != nil { | ||
| 78 | closeOpened() | 80 | closeOpened() | ||
| @@ -108,6 +110,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 108 | tb.ctx, tb.cancel = context.WithCancel(sctx) | 110 | tb.ctx, tb.cancel = context.WithCancel(sctx) | ||
| 109 | opened = append(opened, tb) | 111 | opened = append(opened, tb) | ||
| 110 | for _, paneSnap := range tabSnap.Panes { | 112 | for _, paneSnap := range tabSnap.Panes { | ||
| 113 | + | if decision := planProcessRestore(paneSnap.Process, allowlist); decision.Restore { | |||
| 114 | + | restoreCommands[paneSnap.ID] = decision.Command | |||
| 115 | + | } | |||
| 111 | if err := ctx.Err(); err != nil { | 116 | if err := ctx.Err(); err != nil { | ||
| 112 | closeOpened() | 117 | closeOpened() | ||
| 113 | return err | 118 | return err | ||
| @@ -136,6 +141,11 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||||
| 136 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | 141 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | ||
| 137 | p.rect = contentRect | 142 | p.rect = contentRect | ||
| 138 | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | 143 | seedPaneRows(p, paneSnap.Scrollback, paneSnap.Visible) | ||
| 144 | + | if command := restoreCommands[paneSnap.ID]; command != "" { | |||
| 145 | + | if _, err := pty.Write([]byte(command + "\n")); err != nil { | |||
| 146 | + | d.log.Warn("writing snapshot restore command failed", "err", err, "session", snap.Name, "pane", paneSnap.ID) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 139 | tb.panes[paneSnap.ID] = p | 149 | tb.panes[paneSnap.ID] = p | ||
| 140 | } | 150 | } | ||
| 141 | } | 151 | } | ||
M internal/usecase/daemon/snapshot_restore_test.go +82 -7
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/binary" | |
| 6 | + | "errors" | |
| 6 | 7 | "io" | |
| 7 | 8 | "math" | |
| 8 | 9 | "sync" | |
| @@ -93,6 +94,65 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 93 | 94 | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | |
| 94 | 95 | } | |
| 95 | 96 | ||
| 97 | + | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | |
| 98 | + | t.Helper() | |
| 99 | + | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 100 | + | Name: name, | |
| 101 | + | Tabs: []snapcodec.Tab{{ | |
| 102 | + | Cols: 80, | |
| 103 | + | Rows: 24, | |
| 104 | + | Tree: layout.NewTree("pane-1"), | |
| 105 | + | Panes: []snapcodec.Pane{{ | |
| 106 | + | ID: "pane-1", | |
| 107 | + | Cwd: "/tmp", | |
| 108 | + | Process: proc, | |
| 109 | + | }}, | |
| 110 | + | }}, | |
| 111 | + | })}}} | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestRestoreSnapshotsWritesAllowlistedProcessCommands(t *testing.T) { | |
| 115 | + | store := processRestoreTestStore(t, "proc", &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric}) | |
| 116 | + | factory := &restorePTYFactory{} | |
| 117 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 118 | + | WithSnapshotStore(store)(d) | |
| 119 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"less"}}}) | |
| 120 | + | ||
| 121 | + | d.restoreSnapshots(context.Background()) | |
| 122 | + | ||
| 123 | + | require.Len(t, factory.opens, 1) | |
| 124 | + | require.Equal(t, []string{"less README.md\n"}, factory.opens[0].pty.writes) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestRestoreSnapshotsSkipsDeniedProcessCommands(t *testing.T) { | |
| 128 | + | store := processRestoreTestStore(t, "proc", &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric}) | |
| 129 | + | factory := &restorePTYFactory{} | |
| 130 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 131 | + | WithSnapshotStore(store)(d) | |
| 132 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"vim"}}}) | |
| 133 | + | ||
| 134 | + | d.restoreSnapshots(context.Background()) | |
| 135 | + | ||
| 136 | + | require.Len(t, factory.opens, 1) | |
| 137 | + | require.Empty(t, factory.opens[0].pty.writes) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestRestoreSnapshotsWritesAgentIDCommandAndWriteFailureIsNonFatal(t *testing.T) { | |
| 141 | + | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |
| 142 | + | store := processRestoreTestStore(t, "agent", proc) | |
| 143 | + | factory := &restorePTYFactory{} | |
| 144 | + | d := newTestDaemon(t, factory, stubClock{}) | |
| 145 | + | WithSnapshotStore(store)(d) | |
| 146 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |
| 147 | + | ||
| 148 | + | d.restoreSnapshots(context.Background()) | |
| 149 | + | ||
| 150 | + | require.Len(t, factory.opens, 1) | |
| 151 | + | require.Equal(t, []string{"pi --session-id abc123\n"}, factory.opens[0].pty.writes) | |
| 152 | + | factory.writeErr = errors.New("boom") | |
| 153 | + | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent2", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}})) | |
| 154 | + | } | |
| 155 | + | ||
| 96 | 156 | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | |
| 97 | 157 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | |
| 98 | 158 | Name: "stacked", | |
| @@ -274,26 +334,33 @@ func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |||
| 274 | 334 | } | |
| 275 | 335 | ||
| 276 | 336 | type restorePTYFactory struct { | |
| 277 | - | mu sync.Mutex | |
| 278 | - | opens []restorePTYOpen | |
| 337 | + | mu sync.Mutex | |
| 338 | + | opens []restorePTYOpen | |
| 339 | + | writeErr error | |
| 279 | 340 | } | |
| 280 | 341 | ||
| 281 | 342 | type restorePTYOpen struct { | |
| 282 | 343 | dir string | |
| 283 | 344 | size domain.Size | |
| 284 | 345 | env []string | |
| 346 | + | pty *restorePTY | |
| 285 | 347 | } | |
| 286 | 348 | ||
| 287 | 349 | func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | |
| 288 | 350 | f.mu.Lock() | |
| 289 | 351 | defer f.mu.Unlock() | |
| 290 | - | f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...)}) | |
| 291 | - | return newRestorePTY(), nil | |
| 352 | + | pty := newRestorePTY() | |
| 353 | + | pty.writeErr = f.writeErr | |
| 354 | + | f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty}) | |
| 355 | + | return pty, nil | |
| 292 | 356 | } | |
| 293 | 357 | ||
| 294 | 358 | type restorePTY struct { | |
| 295 | - | done chan struct{} | |
| 296 | - | once sync.Once | |
| 359 | + | mu sync.Mutex | |
| 360 | + | writes []string | |
| 361 | + | writeErr error | |
| 362 | + | done chan struct{} | |
| 363 | + | once sync.Once | |
| 297 | 364 | } | |
| 298 | 365 | ||
| 299 | 366 | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | |
| @@ -301,7 +368,15 @@ func (p *restorePTY) Read([]byte) (int, error) { | |||
| 301 | 368 | <-p.done | |
| 302 | 369 | return 0, io.EOF | |
| 303 | 370 | } | |
| 304 | - | func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil } | |
| 371 | + | func (p *restorePTY) Write(b []byte) (int, error) { | |
| 372 | + | p.mu.Lock() | |
| 373 | + | defer p.mu.Unlock() | |
| 374 | + | p.writes = append(p.writes, string(b)) | |
| 375 | + | if p.writeErr != nil { | |
| 376 | + | return 0, p.writeErr | |
| 377 | + | } | |
| 378 | + | return len(b), nil | |
| 379 | + | } | |
| 305 | 380 | func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil } | |
| 306 | 381 | func (p *restorePTY) Resize(domain.Size) error { return nil } | |
| 307 | 382 | func (p *restorePTY) Pid() int { return 0 } | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/binary" | 5 | "encoding/binary" | ||
| 6 | + | "errors" | |||
| 6 | "io" | 7 | "io" | ||
| 7 | "math" | 8 | "math" | ||
| 8 | "sync" | 9 | "sync" | ||
| @@ -93,6 +94,65 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||||
| 93 | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | 94 | require.Contains(t, factory.opens[1].env, "VEV=session=work,tab="+tabStableID+",pane="+pane2StableID) | ||
| 94 | } | 95 | } | ||
| 95 | 96 | ||||
| 97 | + | func processRestoreTestStore(t *testing.T, name string, proc *snapcodec.Process) *restoreSnapshotStore { | |||
| 98 | + | t.Helper() | |||
| 99 | + | return &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: name, Data: mustSnapshotBytes(t, snapcodec.Session{ | |||
| 100 | + | Name: name, | |||
| 101 | + | Tabs: []snapcodec.Tab{{ | |||
| 102 | + | Cols: 80, | |||
| 103 | + | Rows: 24, | |||
| 104 | + | Tree: layout.NewTree("pane-1"), | |||
| 105 | + | Panes: []snapcodec.Pane{{ | |||
| 106 | + | ID: "pane-1", | |||
| 107 | + | Cwd: "/tmp", | |||
| 108 | + | Process: proc, | |||
| 109 | + | }}, | |||
| 110 | + | }}, | |||
| 111 | + | })}}} | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func TestRestoreSnapshotsWritesAllowlistedProcessCommands(t *testing.T) { | |||
| 115 | + | store := processRestoreTestStore(t, "proc", &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric}) | |||
| 116 | + | factory := &restorePTYFactory{} | |||
| 117 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 118 | + | WithSnapshotStore(store)(d) | |||
| 119 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"less"}}}) | |||
| 120 | + | ||||
| 121 | + | d.restoreSnapshots(context.Background()) | |||
| 122 | + | ||||
| 123 | + | require.Len(t, factory.opens, 1) | |||
| 124 | + | require.Equal(t, []string{"less README.md\n"}, factory.opens[0].pty.writes) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestRestoreSnapshotsSkipsDeniedProcessCommands(t *testing.T) { | |||
| 128 | + | store := processRestoreTestStore(t, "proc", &snapcodec.Process{Argv: []string{"less", "README.md"}, Strategy: processStrategyGeneric}) | |||
| 129 | + | factory := &restorePTYFactory{} | |||
| 130 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 131 | + | WithSnapshotStore(store)(d) | |||
| 132 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"vim"}}}) | |||
| 133 | + | ||||
| 134 | + | d.restoreSnapshots(context.Background()) | |||
| 135 | + | ||||
| 136 | + | require.Len(t, factory.opens, 1) | |||
| 137 | + | require.Empty(t, factory.opens[0].pty.writes) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestRestoreSnapshotsWritesAgentIDCommandAndWriteFailureIsNonFatal(t *testing.T) { | |||
| 141 | + | proc := &snapcodec.Process{Argv: []string{"pi"}, Strategy: processStrategyPi, Opts: snapcodec.ProcessOpts{AgentSessionID: "abc123"}} | |||
| 142 | + | store := processRestoreTestStore(t, "agent", proc) | |||
| 143 | + | factory := &restorePTYFactory{} | |||
| 144 | + | d := newTestDaemon(t, factory, stubClock{}) | |||
| 145 | + | WithSnapshotStore(store)(d) | |||
| 146 | + | d.ApplyConfig(domain.Config{Snapshot: domain.SnapshotConfig{RestoreProcessesSet: true, RestoreProcesses: []string{"pi"}}}) | |||
| 147 | + | ||||
| 148 | + | d.restoreSnapshots(context.Background()) | |||
| 149 | + | ||||
| 150 | + | require.Len(t, factory.opens, 1) | |||
| 151 | + | require.Equal(t, []string{"pi --session-id abc123\n"}, factory.opens[0].pty.writes) | |||
| 152 | + | factory.writeErr = errors.New("boom") | |||
| 153 | + | require.NoError(t, d.restoreSession(context.Background(), snapcodec.Session{Name: "agent2", Tabs: []snapcodec.Tab{{Cols: 80, Rows: 24, Tree: layout.NewTree("pane-1"), Panes: []snapcodec.Pane{{ID: "pane-1", Cwd: "/tmp", Process: proc}}}}})) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 96 | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | 156 | func TestRestoreSnapshotsOpensCollapsedStackPanesWithValidPTYSize(t *testing.T) { | ||
| 97 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | 157 | store := &restoreSnapshotStore{blobs: []ports.SnapshotBlob{{Name: "stacked", Data: mustSnapshotBytes(t, snapcodec.Session{ | ||
| 98 | Name: "stacked", | 158 | Name: "stacked", | ||
| @@ -274,26 +334,33 @@ func (s *restoreSnapshotStore) Load() ([]ports.SnapshotBlob, error) { | |||||
| 274 | } | 334 | } | ||
| 275 | 335 | ||||
| 276 | type restorePTYFactory struct { | 336 | type restorePTYFactory struct { | ||
| 277 | - | mu sync.Mutex | 337 | + | mu sync.Mutex |
| 278 | - | opens []restorePTYOpen | 338 | + | opens []restorePTYOpen |
| 339 | + | writeErr error | |||
| 279 | } | 340 | } | ||
| 280 | 341 | ||||
| 281 | type restorePTYOpen struct { | 342 | type restorePTYOpen struct { | ||
| 282 | dir string | 343 | dir string | ||
| 283 | size domain.Size | 344 | size domain.Size | ||
| 284 | env []string | 345 | env []string | ||
| 346 | + | pty *restorePTY | |||
| 285 | } | 347 | } | ||
| 286 | 348 | ||||
| 287 | func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | 349 | func (f *restorePTYFactory) Open(_ string, _ []string, env []string, dir string, sz domain.Size) (ports.PTY, error) { | ||
| 288 | f.mu.Lock() | 350 | f.mu.Lock() | ||
| 289 | defer f.mu.Unlock() | 351 | defer f.mu.Unlock() | ||
| 290 | - | f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...)}) | 352 | + | pty := newRestorePTY() |
| 291 | - | return newRestorePTY(), nil | 353 | + | pty.writeErr = f.writeErr |
| 354 | + | f.opens = append(f.opens, restorePTYOpen{dir: dir, size: sz, env: append([]string(nil), env...), pty: pty}) | |||
| 355 | + | return pty, nil | |||
| 292 | } | 356 | } | ||
| 293 | 357 | ||||
| 294 | type restorePTY struct { | 358 | type restorePTY struct { | ||
| 295 | - | done chan struct{} | 359 | + | mu sync.Mutex |
| 296 | - | once sync.Once | 360 | + | writes []string |
| 361 | + | writeErr error | |||
| 362 | + | done chan struct{} | |||
| 363 | + | once sync.Once | |||
| 297 | } | 364 | } | ||
| 298 | 365 | ||||
| 299 | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | 366 | func newRestorePTY() *restorePTY { return &restorePTY{done: make(chan struct{})} } | ||
| @@ -301,7 +368,15 @@ func (p *restorePTY) Read([]byte) (int, error) { | |||||
| 301 | <-p.done | 368 | <-p.done | ||
| 302 | return 0, io.EOF | 369 | return 0, io.EOF | ||
| 303 | } | 370 | } | ||
| 304 | - | func (p *restorePTY) Write(b []byte) (int, error) { return len(b), nil } | 371 | + | func (p *restorePTY) Write(b []byte) (int, error) { |
| 372 | + | p.mu.Lock() | |||
| 373 | + | defer p.mu.Unlock() | |||
| 374 | + | p.writes = append(p.writes, string(b)) | |||
| 375 | + | if p.writeErr != nil { | |||
| 376 | + | return 0, p.writeErr | |||
| 377 | + | } | |||
| 378 | + | return len(b), nil | |||
| 379 | + | } | |||
| 305 | func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil } | 380 | func (p *restorePTY) Close() error { p.once.Do(func() { close(p.done) }); return nil } | ||
| 306 | func (p *restorePTY) Resize(domain.Size) error { return nil } | 381 | func (p *restorePTY) Resize(domain.Size) error { return nil } | ||
| 307 | func (p *restorePTY) Pid() int { return 0 } | 382 | func (p *restorePTY) Pid() int { return 0 } | ||